Skip to content

feat(mode)!: posture ladder off|notify|full|all, settable from IM - #67

Merged
y49 merged 26 commits into
mainfrom
feat/posture-ladder
Jul 30, 2026
Merged

feat(mode)!: posture ladder off|notify|full|all, settable from IM#67
y49 merged 26 commits into
mainfrom
feat/posture-ladder

Conversation

@y49

@y49 y49 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

What this changes

tlive's posture becomes a four-rung ladder instead of a three-rung mode plus a separate,
config-only approvals.holdSubagents flag. Both answered the same question — how much does
tlive intercept
— through two concepts, two storage locations and two entrances, one of which
had no CLI at all.

tlive mode off      # hooks are no-ops
tlive mode notify   # watch + notify only; approvals stay native   (default)
tlive mode full     # hold main-session approvals, in parallel with the terminal dialog
tlive mode all      # hold sub-agent approvals too

full and all are separate rungs because the two holds have asymmetric cost, measured on a
real machine: holding a main-session approval takes nothing away (CC renders its terminal dialog
in parallel and the first answer wins), while holding a sub-agent's destroys that dialog (CC
awaits the PermissionRequest hook before deciding whether to build one). So full is the
at-the-keyboard posture and all is the nobody-is-here posture.

The posture is now reachable from the phone

Realising you have left the keyboard happens after you leave, so /mode off|notify|full|all
works from IM, and a bare /mode replies with the ladder and marks the current rung. Sub-agent
cards carry one-tap posture switches: Hold sub-agents from now on on full's pass-through
notice, Stop holding sub-agents on all's held card.

A held sub-agent can be handed back

all costs you the terminal dialog, so Answer at the terminal instead releases one held
request — a new handback decision that maps to defer on the wire (CC then builds the dialog
immediately) but settles the card as "Handed back to the terminal" rather than lying about a
timeout.

Bugs found and fixed along the way

  • The daemon read the posture once at boot, so tlive mode … never reached the sub-agent
    branch — the switch was effectively write-only. It now reads the current posture per decision.
  • A second cached-posture consumer (the permission_prompt path) would have silenced the only
    "a dialog is waiting" signal after flipping to notify from the phone, re-opening notify mode (default): permission prompts produce zero local notification; dashboard never shows waiting-approval #49.
  • sessionStartOut told the model that remote approval was OFF on all, and named the command
    that downgrades the away-posture.
  • A blocked sub-agent was announced on IM only — no desktop toast, no dashboard card — even
    though full is precisely the "you are at this machine" posture. /mute also silenced the
    desktop toast, against the IM ⊥ desktop rule.
  • The desktop "something is waiting" toast could be closed by an unrelated session's approval
    resolving, while a sub-agent was still waiting.
  • A notification arriving before the daemon had registered its session went out with no session
    label
    — reproducible after any daemon restart.
  • The retired sub-agent card kept its "Waiting at the terminal" line under a header saying the
    tool had already run, and used _italic_, which the Telegram renderer deliberately does not
    support (snake_case would become italics) — so the underscores reached the user verbatim.

BREAKING CHANGE

approvals.holdSubagents is removed. Use tlive mode all. A stale key in config.json is
simply ignored; the migration note is in both READMEs and the CHANGELOG footer.

Verification

pnpm run typecheck 0 errors · pnpm test 717/717 · pnpm run build clean.
21 commits. Bundled plugin 2.5.3 → 2.5.5 with the content lock refreshed.

Docs updated in lockstep: README ×2, docs/commands.md, KERNEL.md, docs/getting-started{,-cn}.md,
docs/manual-hooks.md, both SKILL.md, the Telegram command menu and tlive --help.

Not covered by CI — needs a real machine

  1. On full, a sub-agent prompt → notice arrives → Hold sub-agents from now on → the next
    sub-agent approval in that run arrives as an answerable card.
  2. On all, Answer at the terminal instead → the dialog appears in the terminal at once and
    the card settles as "Handed back to the terminal".
  3. Bare /mode on the phone → four buttons, current rung marked → switch → tlive status agrees.
  4. Main-session approvals unchanged on both full and all (terminal dialog still parallel,
    first answer wins).
  5. tlive mode all from the phone, then restart the daemon → posture survives (it lives in
    config.json).

y49 added 26 commits July 29, 2026 11:34
…hing

A sub-agent's approval is handed straight back to CC so its terminal dialog
appears undelayed. But tlive holds the entire request at that instant — tool
name, whole input, agentId — and threw all of it away, returning before
renderCard was even called. The only other signal was CC's own permission_prompt
notification, which carries no tool_name and no agent_id, so it could say nothing
useful and could not be attributed to a dialog; full mode drops it for exactly
that reason. Net effect: a blocked sub-agent was invisible.

Push what we already know instead, via a new onPassthrough hook on the router:
the tool and its rendered input, with NO Allow/Deny buttons. Those buttons cannot
work — CC awaits the hook before constructing the dialog, so by the time the
dialog exists this hook invocation is over and there is nothing left to answer
through. An affordance that cannot work is worse than none.

The notice retires itself. (agentId, toolName) is the one pair carried by BOTH
the pass-through and the sub-agent's later PostToolUse — measured on a live
session, same agentId and same tool name on both ends — so when the tool runs,
the notice is edited to say it ran at the terminal. agentId is required rather
than optional in that match: parallel sub-agents share key and tool name, so
matching without it would retire a sibling's notice. Tracked in its own map
rather than sentCards, because these carry no requestId, answer nothing, and must
stay out of the reply-routing and stale-card machinery that indexes real
approvals.

Full mode no longer builds the local-waiting chain from permission_prompt at all,
which removes a card that nothing could retire. Retiring it needs the answer, and
the only signal tlive gets is the sub-agent's PostToolUse — which that path must
ignore, since a sibling's activity must not clear the main session's reminder.
Measured live: a workflow session sat at `status: active` with a "Permission
needed" card still on it, because every event in that session was a sub-agent's.
Not creating it leaves nothing to retire, and in full mode it was redundant
anyway: every request tlive sees is either held (an answerable card owns every
surface) or handed back (now announced above). `notify` mode is unchanged — there
tlive never gates, so that text is the only signal a dialog is waiting.

Sub-agent remote approval itself is untouched and already works when opted in:
measured hold → answer from IM → the tool ran 80ms later, no local prompt at any
point. What is missing is a local surface during that hold, which is a separate
change.
A literal NUL made file(1) call the source binary and made grep/rg skip it,
so searches for symbols in the daemon's largest file silently returned nothing.
MODES/MODE_DESC/writeMode become the single source for the ladder so the CLI,
status and IM stop carrying three separately-worded copies.
…tests

Test file contained a literal NUL byte in a documentation comment. Rewrote the
comment safely and expanded the hygiene guard to walk both src and tests
directories, preventing regressions in test infrastructure itself.
…tops caching the posture

The daemon read config.json once at boot, so a posture change never reached the
sub-agent branch — the switch was effectively write-only. It now reads the
current mode per decision, which is also what makes a remote /mode possible.

BREAKING CHANGE: approvals.holdSubagents is removed. Run `tlive mode all` to
hold sub-agent approvals for a remote answer (and `tlive mode full` to go back).
… demand

A sub-agent held on 'all' has no terminal dialog; one tap now ends the hold so
CC builds it, instead of waiting out the window. Settles as 'handback' so the
card reads 'Handed back to the terminal' rather than 'Timed out'.
Leaving the keyboard is something you notice after the fact, so the posture has
to be reachable from IM — bare /mode shows the ladder with the current rung
marked, and each rung is one button.
…in it with a test

Review flagged that currentMode()'s comment claimed a deleted config.json
falls back to the boot-time posture, same as a JSON parse failure — it
doesn't. loadConfig's DEFAULT branch for a missing file never throws, so
currentMode() takes the same path the hook shim's readMode() takes for the
identical (absent) file and lands on the same 'notify' default: daemon and
shim can't disagree over whether the file exists, and modeShortCircuit
already short-circuits permission-request to '{}' before any IPC in that
state, so this daemon-side value is unobservable through the real hook path
anyway. Rewrote the comment to say exactly that, and kept the catch branch
(JSON parse failure only) falling back to the boot-time value as a one-shot
anti-flap guard against a transient corrupt write. No behavior change.

Added a test that deletes config.json after boot (bypassing the shim via a
direct IPC request, as the shim itself would never let this reach the
daemon) and pins the resulting sub-agent pass-through.
…a constant

Both the transition message and the ladder card's "(current)" marker now use a
getMode() value that differs from baseDeps' own default, so a hardcoded rung
cannot coincidentally satisfy the assertion — only reading this.deps.getMode()
does.
A pass-through notice can now start holding sub-agents for the rest of the run,
and a held sub-agent card can hand its prompt back to the terminal or leave the
'all' rung — the two things you actually want when the posture is wrong.
The server-side bot command menu gained a /mode entry when the posture
ladder became settable from IM, but this test's hard-coded expected
list still had the old four-command menu, leaving it red.
The permission_prompt notification path (bootstrap.ts's hook.notify
handler) cached the posture once at boot (`const mode =
effectiveMode(cfg.mode)`) and never learned about the `all` rung. Two
reachable failures now that the posture is remotely settable:

- Boot in `full`, then tap `notify` on the ladder card -> the shim
  stops holding, but the cached `mode` was still `full`, so the whole
  local-waiting chain (desktop toast / read-only dashboard card / IM
  text) stayed skipped -- the only signal `notify` gives that a prompt
  is waiting. Silently re-opens issue #49.
- Boot in `all` -> the cached value was never `full`, so the chain ran
  anyway in a holding posture, producing a card nothing could retire.

Fix: read the posture live via the existing `currentMode()` helper and
gate on `!== 'notify'` (the only non-holding rung reachable here --
`off` short-circuits in the shim before any IPC). Delete the
now-unused boot-time constant and rewrite the surrounding comment to
describe the corrected condition.

Also: `sessionStartOut`'s SessionStart guard treated `all` as "remote
approval is off" and suggested `tlive mode full`, which is both false
and a downgrade of a posture the user deliberately set. Guard now
treats `full` and `all` as equally satisfying "remote approval is on".

And: the IM `/mode` confirmation stayed silent when a user taps `full`
on a HELD sub-agent card's "Stop holding sub-agents" button (an all ->
full transition) -- that button only changes future requests, but the
request in hand stays held with no terminal dialog. The confirmation
now says so and points at "Answer at the terminal instead".

Also fixes the CLI --help banner's description of `all`, which said
"no terminal dialog until answered" -- wrong in both directions (a
remote answer means no dialog ever appears; a timeout is what makes
one appear). Matches the canonical MODE_DESC wording.
…mode surfacing

- Two regressions for the fix in the previous commit: mirror the
  existing "full mode: nothing held creates no card/toast/IM text"
  test for `mode: 'all'` (would have caught the boot-in-`all` failure),
  and a new test that boots in `full`, flips to `notify` via
  `writeMode` AFTER boot, then fires a permission_prompt -- asserting
  the local-waiting chain runs per the CURRENT posture, not the
  boot-time one (would have caught the boot-in-`full`-then-`notify`
  failure).
- `handback` end to end: fire the `handback:<requestId>` button on a
  held sub-agent card and assert the `hook.permission.result` reply is
  `decision: 'defer'`, and that the settlement edit's title reads
  "Handed back to the terminal" (not "Timed out" -- the entire reason
  `handback` exists as its own Decision instead of reusing `defer`).
  Previously only a type error would have caught the wire-mapping line
  being removed.
- sessionStartOut: `all` also resolves to `{}` (not the "remote
  approval is off" nudge).
- IM `/mode`: the all -> full stale-hold notice fires only on that
  exact transition, not on a same-rung tap or any other transition.
- /help still lists `/mode` alongside the existing `/mute` / `/trust`
  / `/safe` / no-`/desktop` assertions.
- cli-surface.test.ts: the test NAME said "posture: off/notify/full";
  the assertion already covers `all`, only the sentence was stale.
- README.md / README_CN.md: annotate the `approvals` config block --
  `holdSubagents` was removed, `tlive mode all` replaces it (a user
  upgrading with `"holdSubagents": true` still on disk gets no warning
  otherwise, since loadConfig spreads raw JSON). Also widen the
  "Approvals" section header from "(remote approval -- mode: full)" to
  "(mode: full or all)" -- it applies to both holding rungs.
- docs/getting-started.md / -cn.md: add the missing `all` rung to the
  posture walkthrough (previously only documented notify/full).
- docs/manual-hooks.md: "Approval gating only happens in mode: full"
  -> full or all, plus a note that a held sub-agent under `all` has no
  local dialog to race (Claude Code decides whether to build one only
  after the hook returns).
- plugins/claude/plugins/tlive/commands/setup.md: the bot-menu
  verification step now expects /mode alongside /mute /trust /safe
  /help.
- both SKILL.md (claude + codex, kept in lockstep): "IM commands (/mute
  /trust /safe)" -> also names /mode and `tlive mode
  off|notify|full|all`.
- plugins/** changed again: bumped the bundled plugin 2.5.4 -> 2.5.5
  via `node scripts/plugin-lock.mjs --bump patch` (plugin.json x2 +
  content-lock.json).
…ast and dashboard card

onPassthrough (full mode) told only IM that a sub-agent's dialog was handed
back to the terminal — with no IM configured, or /mute on, or simply not
looking at the phone, a blocked sub-agent produced no signal at all. Fire
desktop.ping gated only by desktopOn (IM ⊥ desktop: mute is IM-only, so the
early return that killed the whole announcement on mute is gone), and upsert
a read-only (local: true) dashboard card that never evicts an already-held
main-session approval. Track waiting pass-throughs in a set independent of
the IM notice map so retirement can close the toast/card even when no IM
card was ever sent.
… the new one

onResolved and clearLocalPrompt still read pendingCount()===0 &&
localPrompts.count()===0, unaware of passthruWaiting — so any main-session
approval resolving, or any local prompt clearing, ANYWHERE in the daemon,
silently closed a still-outstanding sub-agent pass-through toast. Extract one
nothingWaiting() predicate covering all three waiting surfaces and call it
from all three sites instead of keeping three copies of the same condition.

Also drops retirePassthruNotice's own events.broadcast on the dashboard
clear — its one call site (hook.event's activity branch) already broadcasts
the merged view right after, mirroring clearLocalPrompt's no-self-broadcast
style.
A notify can be the daemon's first-ever contact with a session — e.g. right
after a daemon restart, for a session that was already running. The registry
lookup that feeds sessionTag() missed, and the very first line sent for that
session (desktop ping/info, IM text) went out with no "<label> · " prefix at
all, which is the one thing an unlabelled "Claude is waiting for your input"
cannot answer on its own.

Register the session immediately after resolveKey, guarded so a pre-existing
entry (mute state, continueId, pending) is never clobbered. hook.continue.request
was checked for the same miss and is already safe — it registers the session
before the continue card renders.
… their label tag

hook.notify's first-contact fix left two more call sites with the same
ordering bug: onPending's and onPassthrough's desktop.ping both render
sessionTag(key) before their own upsert registers the session, so a
session's first-ever tool-permission request (e.g. right after a daemon
restart) produces an unlabelled desktop toast. Add the same guarded
upsert (if (!sessions.get(key)) sessions.upsert(...)) to both, ahead of
the ping.
…t the waiting suffix

retirePassthruNotice rewrote the passed-through card's title to
"<tool> · sub-agent · ran at the terminal" but reused the exact same
body onPassthrough had sent, waiting sentence included — so the card
said the tool both ran and was still waiting to be answered. Store the
base body (tool + input) in passthruNotices and append the waiting
sentence only to the live IM notice, so the retirement edit renders a
body that agrees with its own title while still showing what ran.
telegram-html.ts deliberately does not support underscore emphasis
(ordinary snake_case/file_path content would turn italic on it), so the
onPassthrough waiting sentence's _..._ reached Telegram as literal
underscores instead of italics (real screenshot). Use *…*, the emphasis
form the converter actually supports — already the established idiom
elsewhere in this file (e.g. the continue-card body) and passed through
unchanged by the Feishu card renderer's native lark_md markdown too.
Review caught the three comments overstating it: upsert's patch merge already
preserves an existing entry (muted is not even an UpsertPatch field), so the
guard skips a redundant write and an activity-timestamp bump — it is not what
keeps state safe.
…ctually held

Leaving `all` for `full`/`notify`/`off` all strand already-held sub-agent
requests the same way (no terminal dialog) — the notice was gated on the
narrower `all → full` transition only. Add PermissionRouter.heldSubagentCount()
so the notice fires only when something is truly still held, never a claim
that isn't true.
A held sub-agent card had no terminal dialog running in parallel (answering
remotely is the only way, short of handing it back), unlike a main-session
card — but its title read identically to one. Append the same `· sub-agent`
marker the pass-through notice already uses.
…gents

When departing from 'all' mode with multiple held sub-agent requests,
the notice read 'their card' as if all requests shared one card. Changed
to 'each card' to disambiguate. Added missing test for all → off.
@y49
y49 merged commit 5925c83 into main Jul 30, 2026
4 checks passed
@y49
y49 deleted the feat/posture-ladder branch July 30, 2026 01:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant