feat(mode)!: posture ladder off|notify|full|all, settable from IM - #67
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
tlive's posture becomes a four-rung ladder instead of a three-rung
modeplus a separate,config-only
approvals.holdSubagentsflag. Both answered the same question — how much doestlive intercept — through two concepts, two storage locations and two entrances, one of which
had no CLI at all.
fullandallare separate rungs because the two holds have asymmetric cost, measured on areal 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
fullis theat-the-keyboard posture and
allis 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|allworks from IM, and a bare
/modereplies with the ladder and marks the current rung. Sub-agentcards carry one-tap posture switches: Hold sub-agents from now on on
full's pass-throughnotice, Stop holding sub-agents on
all's held card.A held sub-agent can be handed back
allcosts you the terminal dialog, so Answer at the terminal instead releases one heldrequest — a new
handbackdecision that maps todeferon the wire (CC then builds the dialogimmediately) but settles the card as "Handed back to the terminal" rather than lying about a
timeout.
Bugs found and fixed along the way
tlive mode …never reached the sub-agentbranch — the switch was effectively write-only. It now reads the current posture per decision.
permission_promptpath) would have silenced the only"a dialog is waiting" signal after flipping to
notifyfrom the phone, re-opening notify mode (default): permission prompts produce zero local notification; dashboard never shows waiting-approval #49.sessionStartOuttold the model that remote approval was OFF onall, and named the commandthat downgrades the away-posture.
though
fullis precisely the "you are at this machine" posture./mutealso silenced thedesktop toast, against the IM ⊥ desktop rule.
resolving, while a sub-agent was still waiting.
label — reproducible after any daemon restart.
tool had already run, and used
_italic_, which the Telegram renderer deliberately does notsupport (
snake_casewould become italics) — so the underscores reached the user verbatim.BREAKING CHANGE
approvals.holdSubagentsis removed. Usetlive mode all. A stale key inconfig.jsonissimply ignored; the migration note is in both READMEs and the CHANGELOG footer.
Verification
pnpm run typecheck0 errors ·pnpm test717/717 ·pnpm run buildclean.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, bothSKILL.md, the Telegram command menu andtlive --help.Not covered by CI — needs a real machine
full, a sub-agent prompt → notice arrives → Hold sub-agents from now on → the nextsub-agent approval in that run arrives as an answerable card.
all, Answer at the terminal instead → the dialog appears in the terminal at once andthe card settles as "Handed back to the terminal".
/modeon the phone → four buttons, current rung marked → switch →tlive statusagrees.fullandall(terminal dialog still parallel,first answer wins).
tlive mode allfrom the phone, then restart the daemon → posture survives (it lives inconfig.json).