From 6106e5b60ddbf50b4fd53d9daa0a6d02e4bfdeb5 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 11:34:58 +0800 Subject: [PATCH 01/26] feat(permissions): say what a sub-agent is blocked on, instead of nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/kernel/daemon/__tests__/bootstrap.test.ts | 137 ++++++++++++++++-- src/kernel/daemon/bootstrap.ts | 98 ++++++++++--- src/kernel/daemon/permission-router.ts | 20 +++ 3 files changed, 224 insertions(+), 31 deletions(-) diff --git a/src/kernel/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index f8fa4d9e..b102f7a9 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -956,6 +956,114 @@ describe('Codex resume handler → continue card (regression: sentinel mismatch) }); }); +// A sub-agent's request is handed straight back to CC so its terminal dialog +// appears undelayed — but tlive holds the full request in its hands at that +// moment (tool name, whole input, agentId) and used to throw all of it away. The +// only thing the user then got was CC's own permission_prompt notification, which +// carries no tool name and no agentId, so it could say nothing useful and could +// not be attributed; full mode suppresses it for that reason. Result: a blocked +// sub-agent was invisible. Push what we already know instead. +// +// No Allow/Deny buttons: that dialog can only be answered at the keyboard (CC +// awaits the hook before building it, so the hook invocation is over by the time +// the dialog exists). Buttons that cannot work are worse than none. +describe('sub-agent pass-through tells you what is blocked', () => { + const CFG = { + mode: 'full', + web: { enabled: false }, + approvals: { approvalGraceSec: 0 }, + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + }; + + it('pushes the tool and its input, without answer buttons', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter] }); + const sock = daemonSocketPath(tmp); + + const r = await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + // Handed back untouched — the terminal dialog must not be delayed. + expect(r).toEqual({ kind: 'hook.permission.result', decision: 'defer' }); + + await waitForSent(sent); + const msg = sent[0] as { kind: string; title?: string; body?: string; text?: string; buttons?: Array<{ id: string }> }; + const shown = `${msg.title ?? ''}\n${msg.body ?? ''}\n${msg.text ?? ''}`; + expect(shown).toContain('Bash'); // what tool + expect(shown).toContain('rm -rf /tmp/scratch'); // and what it wants to do + // Nothing that pretends to be answerable from here. + const ids = (msg.buttons ?? []).map((b) => b.id); + expect(ids.filter((id) => id.startsWith('approve:') || id.startsWith('deny:'))).toEqual([]); + }); + + // The notice has to retire itself, or it becomes a card that still says + // "waiting" long after the thing was answered. (agentId, toolName) is what + // makes that possible: measured on a live session, the sub-agent's PostToolUse + // comes back carrying the SAME pair the pass-through carried, so the two ends + // can be matched. Note the notification path could never do this — CC's + // permission_prompt has no agentId at all. + it('retires the notice once that sub-agent tool actually runs', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const edits: Array<{ messageId: string; msg: OutgoingMessage }> = []; + const adapter = interactiveAdapter('telegram', sent, edits); + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter] }); + const sock = daemonSocketPath(tmp); + + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await waitForSent(sent); + + // The answer happened at the keyboard; the tool then ran, which is the only + // thing tlive ever learns about the outcome. + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', agentId: 'a-77', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + + await until(() => { expect(edits.length).toBeGreaterThanOrEqual(1); }); + expect(JSON.stringify(edits[0].msg)).toMatch(/ran at the terminal/i); + }); + + it('leaves the notice alone when a DIFFERENT sub-agent runs the same tool', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const edits: Array<{ messageId: string; msg: OutgoingMessage }> = []; + const adapter = interactiveAdapter('telegram', sent, edits); + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter] }); + const sock = daemonSocketPath(tmp); + + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await waitForSent(sent); + + // A sibling sub-agent's Bash. Parallel sub-agents share key + toolName, so + // ignoring agentId here would retire the wrong notice. + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', agentId: 'a-99', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await new Promise((r) => setTimeout(r, 120)); + expect(edits).toEqual([]); + }); +}); + // Telegram caps callback_data at 64 BYTES and rejects the ENTIRE sendMessage // (BUTTON_DATA_INVALID) when any button exceeds it. `allowtool::` // spends 47 bytes before the name, so any tool named longer than 17 characters @@ -1054,15 +1162,22 @@ describe('permission_prompt forwarding — the notify-mode / immediate-defer not expect((sent[0] as { text: string }).text).toContain('answer in the terminal'); }); - // The baseline contract. In `full` mode every permission request tlive sees - // ends up either held (an answerable card owns IM) or deliberately passed - // through (sub-agent pass-through / policy allow). "Passed through" means "act - // as if tlive were not installed" — so an un-answerable "go look at your - // terminal" text is a message the baseline never sends, and it cannot be - // attributed anyway (CC's Notification input carries no agent_id and no - // tool_name). Desktop + dashboard are tlive's own surfaces, not additions to - // CC's output, so they stay. - it('full mode + no held card (tlive passed through) → desktop + dashboard, but NO IM text', async () => { + // In `full` mode every request tlive sees ends up either held (an answerable + // card owns every surface) or deliberately handed back. So a permission_prompt + // arriving with nothing held means tlive handed that one back — and for the + // only case that produces a dialog, a sub-agent pass-through, tlive has already + // pushed a notice naming the tool and its input. This event adds nothing: it + // carries no tool_name and no agent_id, so it cannot even say which dialog it + // means. + // + // Building the chain from it anyway is what produced a card that never went + // away: retiring it needs the answer, and the only signal tlive gets is the + // sub-agent's PostToolUse, which is skipped here precisely because it carries an + // agentId (a sibling's activity must not retire 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. Don't create it, and there is nothing to retire. + it('full mode: a permission_prompt with nothing held creates no card, toast or IM text', async () => { writeFileSync(join(tmp, 'config.json'), JSON.stringify({ ...CFG, mode: 'full' })); const sent: OutgoingMessage[] = []; const adapter = interactiveAdapter('telegram', sent); @@ -1072,9 +1187,9 @@ describe('permission_prompt forwarding — the notify-mode / immediate-defer not await request({ kind: 'hook.notify', cwd: '/w', sessionId: 's1', level: 'info', message: MSG, permissionPrompt: true }, { socketPath: sock, timeoutMs: 2000 }); - expect(notes).toHaveLength(1); - expect((await findSession(sock, 's1'))?.pending?.local).toBe(true); await new Promise((r) => setTimeout(r, 80)); + expect(notes).toEqual([]); + expect((await findSession(sock, 's1'))?.pending ?? null).toBeNull(); expect(sent).toHaveLength(0); }); diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index ebe2c008..63685679 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -352,7 +352,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise void }, ): Promise => { const adapter = (opts.imAdapters ?? []).find((a) => a.channel === target.channel); if (!adapter) return; @@ -407,6 +407,36 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise), and only this one caller needs the id back. + msg.onSent?.({ channel: target.channel, messageId: sent.messageId }); + }; + + /** Sub-agent pass-through notices, so each can retire itself when that + * sub-agent's tool actually runs. Keyed by (key, agentId, toolName) — the one + * pair carried by BOTH the pass-through and the later PostToolUse, verified on + * a live session. Kept separate from `sentCards` on purpose: these carry no + * requestId, answer nothing, and must stay out of the reply-routing and + * stale-card machinery that indexes real approvals. */ + const passthruNotices = new Map>(); + const passthruKey = (key: string, agentId: string, toolName: string): string => `${key}${agentId}${toolName}`; + + /** The sub-agent's tool ran, so its dialog was answered at the keyboard. Mark + * the notice so it stops reading as "still waiting". */ + const retirePassthruNotice = (key: string, agentId: string, toolName: string): void => { + const id = passthruKey(key, agentId, toolName); + const notices = passthruNotices.get(id); + if (!notices) return; + passthruNotices.delete(id); + for (const n of notices) { + const adapter = (opts.imAdapters ?? []).find((a) => a.channel === n.channel); + if (!adapter) continue; + void adapter.edit(n.messageId, { + kind: 'card', + title: `${toolName} · sub-agent · ran at the terminal`, + body: n.body, + }).catch(() => undefined); + } }; // Reap wrapped sessions whose `tlive run` process died without unregistering (kill -9 / crash). @@ -452,6 +482,32 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise cfg.approvals?.timeoutAction ?? 'defer', log: logJson, + // A sub-agent's approval was handed back to CC (its terminal dialog is what + // answers it). Say what is blocked while we still know — CC's own + // permission_prompt notification carries no tool name and no agentId, so it + // could never say this, which is why full mode drops it. Deliberately NO + // requestId: sendToChat only attaches Allow/Deny when one is present, and an + // affordance that cannot work is worse than none (see onPassthrough). + onPassthrough: ({ key, cwd, agentId, toolName, title, body }) => { + logJson('permission.passthrough.notice', { key, agentId, toolName }); + if (muted || sessions.get(key)?.muted) return; + const notice = `${body}\n\n_Waiting at the terminal — a sub-agent's prompt can only be answered there._`; + for (const t of configuredChats()) { + void sendToChat(t, { + title: `${toolName} · sub-agent`, body: notice, cwd: key, + onSent: (s) => { + const id = passthruKey(key, agentId, toolName); + const list = passthruNotices.get(id) ?? []; + list.push({ channel: s.channel, messageId: s.messageId, body: notice }); + passthruNotices.set(id, list); + }, + }) + .catch((e: unknown) => { + logJson('permission.passthrough.undelivered', { key, agentId, toolName, channel: t.channel, error: e instanceof Error ? e.message : String(e) }); + }); + } + void cwd; // the registry key is what routes replies; cwd is display-only here + }, onPending: ({ key, cwd, requestId, title, body, toolName, ask }) => { // Desktop ping FIRST — this notification is for the person AT this // machine, so it must be immediate (the IM card's grace delay exists to @@ -835,16 +891,26 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { // Each bail-out is logged with its own tag: "no IM text arrived" // has four different causes and they are not interchangeable. const note = (outcome: string, extra?: Record): void => logJson('permission.localPrompt.im', { key, ...(req.sessionId ? { sessionId: req.sessionId } : {}), outcome, ...extra }); - if (mode === 'full') return note('full-mode-passthrough'); if (!localPrompts.has(key, req.sessionId)) return note('answered-in-grace'); if (permissionRouter.hasPendingFor({ key, sessionId: req.sessionId })) return note('raced-held-card'); if (muted || sessions.get(key)?.muted) return note('muted'); @@ -932,6 +986,10 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise void; + /** Fired when a request is handed straight back to the vendor instead of being + * held — today only the sub-agent pass-through. The point is that tlive is + * holding the whole request at that instant (tool, input, agentId) while the + * only other signal the user would get, CC's permission_prompt notification, + * carries neither tool name nor agentId and so can say nothing useful. Without + * this the blocked sub-agent is invisible. + * + * Informational only: the dialog it refers to can be answered at the keyboard + * and nowhere else, because CC awaits the hook before building the dialog, so + * by the time it exists this hook invocation is over. Consumers must not offer + * an Allow/Deny affordance for it. `agentId` + `toolName` are carried because + * together they are the one pair that also comes back on the sub-agent's + * PostToolUse, which is how the notice gets retired. */ + onPassthrough?: (p: { key: string; cwd: string; agentId: string; toolName: string; title: string; body: string }) => void; /** Fired when the request resolves (answered / timed out / deferred after a card). Same key/cwd split as onPending. * message:带理由的拒绝所携带的文本(引用回复而来)—— 供回写区分 * `Denied` 与 `Denied with guidance`。 @@ -152,6 +166,12 @@ export class PermissionRouter { // auto-allows; opt-in holdSubagents makes sub-agent approvals remotely answerable. if (opts.agentId && !(this.deps.holdSubagents?.() ?? false)) { outcome('subagent-passthrough'); + // Handed back — but say what was handed back, while we still hold the + // details. See onPassthrough for why this is the only chance to. + if (this.deps.onPassthrough) { + const { title, body } = this.deps.renderCard({ toolName: opts.toolName, input: opts.input }); + this.deps.onPassthrough({ key: opts.key, cwd: opts.cwd, agentId: opts.agentId, toolName: opts.toolName, title, body }); + } return { decision: 'defer' }; } From 38c955722619de43af875bdf637ae0311d871e4e Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 13:16:49 +0800 Subject: [PATCH 02/26] fix(daemon): escape the NUL separator so bootstrap.ts stays greppable 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. --- src/kernel/daemon/bootstrap.ts | 2 +- tests/contract/source-hygiene.test.ts | Bin 0 -> 927 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/contract/source-hygiene.test.ts diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 63685679..9df4c227 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -419,7 +419,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise>(); - const passthruKey = (key: string, agentId: string, toolName: string): string => `${key}${agentId}${toolName}`; + const passthruKey = (key: string, agentId: string, toolName: string): string => `${key}\u0000${agentId}\u0000${toolName}`; /** The sub-agent's tool ran, so its dialog was answered at the keyboard. Mark * the notice so it stops reading as "still waiting". */ diff --git a/tests/contract/source-hygiene.test.ts b/tests/contract/source-hygiene.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7aff624542cc5d209516f75726144bee5e7c703e GIT binary patch literal 927 zcmZuwL2lbH5bRm6n42iWkb91i0tMQhie8E$haf;0X=O2yNOieNA|wX-h`z8d=~A`= zyXb*}Nbb(g%r0F&1QxuYrdYANqcxnYLC*tKf;}1zJ@Q8<6eZtgZV6|yb?x~3)TP}4;dPt)&%^XK=2l~yCN&1!{@=(Hf)4&Q!!#oZ)?-ab&oZKG9g zQFS&(*G#a8VdRQnBkr8HtVg~+1ZP+QJEmd7JH!V!s4$Qh$J9;u^XoUHB_u%99EON6 z3PJ;VB!!7&xoV*-QxEC((UW*7;fizQZ@tQmbJF8HI=Xw>!#O=RbH>qL6lT;Jdl z*Ehw<{v`t%C2FdmTtG?RN#_z&Q6NPDhmb)rQghOkjzO`V?_2}3wABnn>Eb7~v68@( zQGi1eq8VmB#~kV+Ateqa`8r;yb0#k(<^^6~adz0>|L-&%7D-?^jIq_SiOXIWlZ@=s z%{JR-*)q8%KMq4X?HqZ^*DwVxKg{}^m|XjIjF`Kwg2if&)c$|R?JRY%CNzx-A&zR% zqBxIS Date: Wed, 29 Jul 2026 13:26:14 +0800 Subject: [PATCH 03/26] feat(mode): add the 'all' rung and one shared posture module MODES/MODE_DESC/writeMode become the single source for the ladder so the CLI, status and IM stop carrying three separately-worded copies. --- src/kernel/config/__tests__/mode.test.ts | 46 ++++++++++++++++++++ src/kernel/config/loader.ts | 12 ++--- src/kernel/config/mode.ts | 39 +++++++++++++++++ src/kernel/hook/__tests__/normalizer.test.ts | 14 ++++++ src/kernel/hook/normalizer.ts | 17 +++++--- 5 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 src/kernel/config/__tests__/mode.test.ts create mode 100644 src/kernel/config/mode.ts diff --git a/src/kernel/config/__tests__/mode.test.ts b/src/kernel/config/__tests__/mode.test.ts new file mode 100644 index 00000000..52a68840 --- /dev/null +++ b/src/kernel/config/__tests__/mode.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MODES, MODE_DESC, writeMode } from '../mode.js'; + +describe('posture ladder', () => { + let home: string; + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'tlive-modemod-')); }); + afterEach(() => { rmSync(home, { recursive: true, force: true }); }); + + it('is the four rungs, in escalation order', () => { + expect(MODES).toEqual(['off', 'notify', 'full', 'all']); + }); + + it('every rung has one description, shared by CLI / status / IM', () => { + for (const m of MODES) expect(MODE_DESC[m].startsWith(`${m} —`)).toBe(true); + }); + + it('writes mode into a missing config (creating the home dir)', () => { + const nested = join(home, 'sub'); + const p = writeMode(nested, 'all'); + expect(JSON.parse(readFileSync(p, 'utf-8')).mode).toBe('all'); + }); + + it('round-trips every other field untouched', () => { + writeFileSync(join(home, 'config.json'), JSON.stringify({ + adapters: { telegram: { token: 't' } }, + approvals: { windowSec: 123 }, + allowedSenders: [{ channel: 'telegram', userId: 'u1' }], + })); + writeMode(home, 'full'); + const cfg = JSON.parse(readFileSync(join(home, 'config.json'), 'utf-8')); + expect(cfg.mode).toBe('full'); + expect(cfg.adapters.telegram.token).toBe('t'); + expect(cfg.approvals.windowSec).toBe(123); + expect(cfg.allowedSenders).toHaveLength(1); + }); + + it('a corrupt config is replaced, not thrown on', () => { + writeFileSync(join(home, 'config.json'), '{ not json'); + writeMode(home, 'notify'); + expect(JSON.parse(readFileSync(join(home, 'config.json'), 'utf-8'))).toEqual({ mode: 'notify' }); + expect(existsSync(join(home, 'config.json'))).toBe(true); + }); +}); diff --git a/src/kernel/config/loader.ts b/src/kernel/config/loader.ts index 304736d5..6c187a7a 100644 --- a/src/kernel/config/loader.ts +++ b/src/kernel/config/loader.ts @@ -1,6 +1,7 @@ // src/kernel/config/loader.ts import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import type { ShimMode } from '../hook/normalizer.js'; export interface AdapterCreds { telegram?: { token: string; chatIdAllowList?: string[] }; @@ -31,9 +32,9 @@ export interface PolicyConfig { autoAllow?: string[]; autoDeny?: string[]; ask?: * 以下三个会改变"没装 tlive 时的行为",是明确的取舍开关: * autoApprove: 不写 = 关(什么都不自动放行)。一旦设置就会抹掉 CC 本该弹出 * 的确认框 —— PermissionRequest 只在 ask 路径触发,见 policy-engine。 - * holdSubagents: true 会让被 hold 的异步子代理失去本地框(见 permission-router)。 + * (子代理拦不拦已并入姿态梯子:`tlive mode all`,见 kernel/config/mode.ts。) * timeoutAction: 'deny' 会在窗口耗尽时替你拒绝(默认 'defer' 回落 CC 原生)。 */ -export interface ApprovalsConfig { windowSec?: number; continueWindowSec?: number; continueGraceSec?: number; approvalGraceSec?: number; desktopNotify?: boolean; autoApprove?: 'readonly' | 'safe'; holdSubagents?: boolean; timeoutAction?: 'defer' | 'deny' } +export interface ApprovalsConfig { windowSec?: number; continueWindowSec?: number; continueGraceSec?: number; approvalGraceSec?: number; desktopNotify?: boolean; autoApprove?: 'readonly' | 'safe'; timeoutAction?: 'defer' | 'deny' } export interface KernelConfig { allowedSenders: Array<{ channel: 'telegram' | 'feishu'; userId: string }>; @@ -42,10 +43,9 @@ export interface KernelConfig { policy?: PolicyConfig; approvals?: ApprovalsConfig; daemon?: { socketPath?: string; healthPort?: number; autoStart?: boolean }; - /** tlive 姿态:'full' = 远程审批 + 监看(卖点全开);'notify' = 只监看/通知, - * 绝不 gating 任何审批(shim 默认,安全);'off' = 全关 kill switch。 - * 缺省时 shim 按 'notify' 处理(见 hook.ts readMode)。 */ - mode?: 'off' | 'notify' | 'full'; + /** 姿态梯子:off | notify | full | all,见 kernel/config/mode.ts。 + * 缺省时按 'notify' 处理(见 hook.ts readMode / effectiveMode)。 */ + mode?: ShimMode; } const DEFAULT: KernelConfig = { allowedSenders: [], adapters: {} }; diff --git a/src/kernel/config/mode.ts b/src/kernel/config/mode.ts new file mode 100644 index 00000000..b01f13da --- /dev/null +++ b/src/kernel/config/mode.ts @@ -0,0 +1,39 @@ +// src/kernel/config/mode.ts +// +// The posture ladder — tlive's ONE answer to "how much does tlive intercept". +// It lives in config.json rather than daemon runtime state for two reasons: the +// hook SHIM reads it on every event (before any IPC, which is what makes +// `notify` unable to hang anything), and "I'm out" must survive a daemon +// restart. Everything that displays or writes a posture goes through this file +// — three separately-worded blurbs (CLI, status, IM) is how they drift apart. +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ShimMode } from '../hook/normalizer.js'; + +/** Escalation order — index = how much tlive intercepts. */ +export const MODES: readonly ShimMode[] = ['off', 'notify', 'full', 'all']; + +export const MODE_DESC: Record = { + off: 'off — hooks are no-ops: no approval gating, no notifications, no monitoring.', + notify: 'notify — watch + notify only; approvals stay 100% native (turn remote approval on with `tlive mode full`).', + full: 'full — main-session approvals are held for a remote answer, in parallel with the terminal dialog (first answer wins). Sub-agent prompts pass through to the terminal.', + all: 'all — sub-agent approvals are held too. A held sub-agent has NO terminal dialog until the window ends, so use this when nobody is at the keyboard (`tlive mode full` to go back).', +}; + +/** Persist the posture; returns the config path written. Reads the RAW file + * (not loadConfig, which allowlists `web`) so every other field round-trips + * untouched — this only ever flips `mode`. An unparsable config is replaced + * rather than thrown on: refusing to write would strand the user with a + * posture they cannot change. */ +export function writeMode(home: string, mode: ShimMode): string { + const path = join(home, 'config.json'); + let cfg: Record = {}; + if (existsSync(path)) { + try { cfg = JSON.parse(readFileSync(path, 'utf-8')); } catch { cfg = {}; } + } else { + mkdirSync(home, { recursive: true }); + } + cfg.mode = mode; + writeFileSync(path, JSON.stringify(cfg, null, 2)); + return path; +} diff --git a/src/kernel/hook/__tests__/normalizer.test.ts b/src/kernel/hook/__tests__/normalizer.test.ts index c083b76d..f5f87cdd 100644 --- a/src/kernel/hook/__tests__/normalizer.test.ts +++ b/src/kernel/hook/__tests__/normalizer.test.ts @@ -208,3 +208,17 @@ describe('subagent 监看事件', () => { expect(n.delta).toBe(1); }); }); + +describe('effectiveMode — the posture ladder', () => { + it('accepts all four rungs verbatim', () => { + expect(effectiveMode('off')).toBe('off'); + expect(effectiveMode('notify')).toBe('notify'); + expect(effectiveMode('full')).toBe('full'); + expect(effectiveMode('all')).toBe('all'); + }); + it('unset / unknown / malformed still falls back to notify (the safe rung)', () => { + expect(effectiveMode(undefined)).toBe('notify'); + expect(effectiveMode('ALL')).toBe('notify'); + expect(effectiveMode(3)).toBe('notify'); + }); +}); diff --git a/src/kernel/hook/normalizer.ts b/src/kernel/hook/normalizer.ts index addca025..9211e4a4 100644 --- a/src/kernel/hook/normalizer.ts +++ b/src/kernel/hook/normalizer.ts @@ -143,17 +143,20 @@ export function permissionRequestDecisionOut(decision: 'allow' | 'deny' | 'defer } -/** tlive 姿态开关。full = 远程审批 + 监看(卖点全开);notify = 只监看/通知, - * PermissionRequest 绝不 gating(默认,安全:tlive 物理上无法 hold 任何审批); - * off = 全关 kill switch。shim 按此短路(见 modeShortCircuit)。 */ -export type ShimMode = 'off' | 'notify' | 'full'; +/** tlive 姿态梯子,单调递增:每一级都做前一级做的事,再多做一点。 + * off = 全关 kill switch;notify = 只监看/通知,PermissionRequest 绝不 gating + * (默认,安全);full = hold 主会话审批(终端框并行,先答先得);all = 子代理 + * 审批也 hold(hold 期间子代理**没有**终端框,所以这是"没人在键盘前"的姿态)。 + * shim 按此短路(见 modeShortCircuit);daemon 按此决定子代理拦不拦。 */ +export type ShimMode = 'off' | 'notify' | 'full' | 'all'; /** Resolve a config `mode` value to the effective posture — the single source of * the `notify` default. Unset / unknown / malformed all fall back to the safe - * `notify` (watch + notify, never gate). Used by both the shim (readMode) and - * `tlive status` so the displayed posture always matches the enforced one. */ + * `notify` (watch + notify, never gate). Used by the shim (readMode), the + * daemon (currentMode) and `tlive status`, so the displayed posture always + * matches the enforced one. */ export function effectiveMode(m: unknown): ShimMode { - return m === 'off' || m === 'full' ? m : 'notify'; + return m === 'off' || m === 'full' || m === 'all' ? m : 'notify'; } /** session-start additionalContext(CC-only,Codex hooks 已退役)。分级引导: From 903d95cdb0e74a2665368279e599f66a45aa578f Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 13:35:57 +0800 Subject: [PATCH 04/26] fix: remove NUL byte from test file and expand guard to both src and 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. --- tests/contract/source-hygiene.test.ts | Bin 927 -> 990 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/contract/source-hygiene.test.ts b/tests/contract/source-hygiene.test.ts index 7aff624542cc5d209516f75726144bee5e7c703e..970a90df1a43d700994bef7e1186bfe326ea8b85 100644 GIT binary patch delta 125 zcmbQwevf^FJ7Y*_ajHT|Myf(_W`%BYMq*K7a!G2Df>*G=pKftUWlpL>YH@O6K`NJm zmV$ytQetv;aZX}!hK@q1j)DOkX<923C+4OqBvqEADkPR{E@iyPD4v|3mz-FlQJh~| Vl$=_upK) delta 63 zcmcb|KA(MqJELBBQD#Z1LP Date: Wed, 29 Jul 2026 13:37:09 +0800 Subject: [PATCH 05/26] feat(cli): tlive mode accepts 'all'; status and setup follow the shared ladder --- src/cli/subcommands/__tests__/mode.test.ts | 11 +++-- .../__tests__/setup-mode-offer.test.ts | 1 + src/cli/subcommands/mode.ts | 41 ++++++------------- src/cli/subcommands/setup.ts | 4 +- src/cli/subcommands/status.ts | 6 +-- 5 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/cli/subcommands/__tests__/mode.test.ts b/src/cli/subcommands/__tests__/mode.test.ts index 09b11e11..761cee28 100644 --- a/src/cli/subcommands/__tests__/mode.test.ts +++ b/src/cli/subcommands/__tests__/mode.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runMode } from '../mode.js'; -describe('tlive mode — persists the posture to config.json', () => { +describe('tlive mode — persists the posture to config.json', () => { let home: string; beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'tlive-mode-')); process.env.TLIVE_HOME = home; }); afterEach(() => { delete process.env.TLIVE_HOME; rmSync(home, { recursive: true, force: true }); }); @@ -15,16 +15,21 @@ describe('tlive mode — persists the posture to config.json', expect(cfg.mode).toBe('full'); }); + it('sets the all rung', () => { + runMode(['all']); + const cfg = JSON.parse(readFileSync(join(home, 'config.json'), 'utf-8')); + expect(cfg.mode).toBe('all'); + }); + it('preserves every other config field when flipping mode', () => { writeFileSync(join(home, 'config.json'), JSON.stringify({ adapters: { telegram: { token: 't' } }, - approvals: { windowSec: 123, holdSubagents: true }, + approvals: { windowSec: 123 }, })); runMode(['notify']); const cfg = JSON.parse(readFileSync(join(home, 'config.json'), 'utf-8')); expect(cfg.mode).toBe('notify'); expect(cfg.adapters.telegram.token).toBe('t'); // untouched expect(cfg.approvals.windowSec).toBe(123); // untouched - expect(cfg.approvals.holdSubagents).toBe(true); // untouched }); }); diff --git a/src/cli/subcommands/__tests__/setup-mode-offer.test.ts b/src/cli/subcommands/__tests__/setup-mode-offer.test.ts index 4f016a85..8ca683e0 100644 --- a/src/cli/subcommands/__tests__/setup-mode-offer.test.ts +++ b/src/cli/subcommands/__tests__/setup-mode-offer.test.ts @@ -13,6 +13,7 @@ describe('shouldOfferFull', () => { }); it('does not re-offer when remote approval is already on', () => { expect(shouldOfferFull('full', true)).toBe(false); + expect(shouldOfferFull('all', true)).toBe(false); // 'all' is ABOVE full — offering would downgrade }); }); diff --git a/src/cli/subcommands/mode.ts b/src/cli/subcommands/mode.ts index 30c99c81..685e9144 100644 --- a/src/cli/subcommands/mode.ts +++ b/src/cli/subcommands/mode.ts @@ -1,41 +1,24 @@ // src/cli/subcommands/mode.ts // -// `tlive mode off|notify|full` — sets tlive's posture. Unlike the runtime toggles -// (/mute /trust /safe /desktop, which flip daemon state via IPC), `mode` is read by -// the hook SHIM straight from config.json on every event, so it must persist there. -// It takes effect immediately: the shim re-reads config on the next hook, no daemon -// restart and no new session needed. -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +// `tlive mode off|notify|full|all` — sets tlive's posture. Unlike the runtime +// toggles (/mute /trust /safe /desktop, which flip daemon state via IPC), `mode` +// is read by the hook SHIM straight from config.json on every event, so it must +// persist there. It takes effect immediately: the shim re-reads config on the +// next hook, no daemon restart and no new session needed. The IM `/mode` command +// writes the same file through the same writer. import { homedir } from 'node:os'; import { join } from 'node:path'; - -const MODES = ['off', 'notify', 'full'] as const; -type Mode = (typeof MODES)[number]; - -const BLURB: Record = { - off: 'off — tlive hooks are no-ops: no approval gating, no notifications, no monitoring.', - notify: 'notify — tlive observes + notifies (IM / dashboard) but NEVER holds an approval; every approval stays fully native (local terminal dialog, or CC auto-deny when headless).', - full: 'full — remote approval ON: tlive holds tool approvals so you can answer them from IM / desktop / dashboard.', -}; +import { MODES, MODE_DESC, writeMode } from '../../kernel/config/mode.js'; +import type { ShimMode } from '../../kernel/hook/normalizer.js'; export function runMode(argv: string[]): void { - const arg = argv[0]; - if (!MODES.includes(arg as Mode)) { + const arg = argv[0] as ShimMode; + if (!MODES.includes(arg)) { process.stderr.write(`Usage: tlive mode ${MODES.join('|')}\n`); process.exit(1); return; // unreachable in prod (process.exit throws); keeps the type checker + tests honest } const home = process.env.TLIVE_HOME ?? join(homedir(), '.tlive'); - const path = join(home, 'config.json'); - // Read the RAW file (not loadConfig, which allowlists `web`) so every other - // field round-trips untouched — this command only flips `mode`. - let cfg: Record = {}; - if (existsSync(path)) { - try { cfg = JSON.parse(readFileSync(path, 'utf-8')); } catch { cfg = {}; } - } else { - mkdirSync(home, { recursive: true }); - } - cfg.mode = arg; - writeFileSync(path, JSON.stringify(cfg, null, 2)); - process.stdout.write(`tlive mode: ${BLURB[arg as Mode]}\n(saved to ${path} — takes effect on the next hook event.)\n`); + const path = writeMode(home, arg); + process.stdout.write(`tlive mode: ${MODE_DESC[arg]}\n(saved to ${path} — takes effect on the next hook event.)\n`); } diff --git a/src/cli/subcommands/setup.ts b/src/cli/subcommands/setup.ts index f26cfbf0..024534a2 100644 --- a/src/cli/subcommands/setup.ts +++ b/src/cli/subcommands/setup.ts @@ -30,7 +30,9 @@ export function resolveVendorSelection(detected: VendorSelection, answer: string // Only offer it when there's a channel to approve to and remote approval isn't // already on — and never downgrade an existing `full`. export function shouldOfferFull(currentMode: unknown, hasChannel: boolean): boolean { - return hasChannel && currentMode !== 'full'; + // 'all' is a HIGHER rung than 'full' — offering "enable remote approval" to + // someone already on 'all' would be offering a downgrade. + return hasChannel && currentMode !== 'full' && currentMode !== 'all'; } // Enabling remote approval is an opt-in escalation, so ONLY an explicit yes diff --git a/src/cli/subcommands/status.ts b/src/cli/subcommands/status.ts index afce804f..da757f51 100644 --- a/src/cli/subcommands/status.ts +++ b/src/cli/subcommands/status.ts @@ -5,6 +5,7 @@ import { homedir } from 'node:os'; import { request } from '../../kernel/ipc/client.js'; import { loadConfig } from '../../kernel/config/loader.js'; import { effectiveMode } from '../../kernel/hook/normalizer.js'; +import { MODE_DESC } from '../../kernel/config/mode.js'; import { resolveWebUrls, printWebBanner } from '../web-url.js'; import { pluginHealth, defaultRunner } from '../../kernel/integrations/plugin-install.js'; @@ -38,11 +39,6 @@ export async function runStatus(_argv: string[]): Promise { const cfg = loadConfig(home); // Posture line — the single most useful diagnostic for "why didn't I get a card?". // Shows the EFFECTIVE mode (same notify-default the shim enforces via effectiveMode). - const MODE_DESC: Record<'off' | 'notify' | 'full', string> = { - full: 'full — remote approval ON (holds tool approvals for IM/desktop/dashboard)', - notify: 'notify — watch + notify only; remote approval OFF (enable: tlive mode full)', - off: 'off — tlive disabled (hooks are no-ops)', - }; process.stdout.write(`mode: ${MODE_DESC[effectiveMode(cfg.mode)]}\n`); const dests: string[] = []; From 9e3f77cdf3a73e2c649c156cad545401185fccad Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 14:26:15 +0800 Subject: [PATCH 06/26] feat(mode)!: 'all' replaces approvals.holdSubagents, and the daemon stops caching the posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/kernel/config/loader.ts | 2 +- .../daemon/__tests__/session-ipc.test.ts | 39 ++++++++++++++++--- src/kernel/daemon/bootstrap.ts | 16 ++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/kernel/config/loader.ts b/src/kernel/config/loader.ts index 6c187a7a..bf6da350 100644 --- a/src/kernel/config/loader.ts +++ b/src/kernel/config/loader.ts @@ -25,7 +25,7 @@ export interface PolicyConfig { autoAllow?: string[]; autoDeny?: string[]; ask?: * 主会话挂起期间 CC 照常并行渲染本地框(实测:每次 hold 起 6 秒必有 * permission_prompt,且有 by=local-terminal 的答复),它只是给"人在电脑前但 * 没盯着这个终端"的场景一个提醒。真正没有本地框的只有被 hold 的异步子代理, - * 见 permission-router 的 holdSubagents。 + * 见 permission-router 的 holdSubagents 回调(由姿态 all 驱动,见 kernel/config/mode.ts)。 * continueWindowSec: async Stop hook 后台等续跑回复的时长(默认 1800)。 * continueGraceSec: turn 结束后等这么久再推续跑卡(默认 15)。 * approvalGraceSec: 审批卡推送前的静默期(默认 10;0=立即发)。 diff --git a/src/kernel/daemon/__tests__/session-ipc.test.ts b/src/kernel/daemon/__tests__/session-ipc.test.ts index 634d799e..c31ef138 100644 --- a/src/kernel/daemon/__tests__/session-ipc.test.ts +++ b/src/kernel/daemon/__tests__/session-ipc.test.ts @@ -8,6 +8,7 @@ import { request, daemonSocketPath } from '../../ipc/client'; import type { SessionMeta } from '../../ipc/protocol'; import type { IMAdapter, OutgoingMessage } from '../../contracts/im-adapter'; import { until } from '../../__tests__/wait.js'; +import { writeMode } from '../../config/mode.js'; const recordingAdapter = (sent: OutgoingMessage[]): IMAdapter => ({ channel: 'telegram', @@ -58,12 +59,13 @@ describe('session.* over IPC', () => { describe('parent-session teardown must be agent-scoped (backgrounded sub-agent approval survival)', () => { // A configured chat keeps requestPermission pending; with no injected imAdapters // sendToChat is a no-op (no network), so the pending simply sits in the router. - // holdSubagents:true — these tests exercise the *held* sub-agent path (the - // cancel-scoping fix). By default sub-agents pass through (no pending to protect), - // so remote-hold must be opted in to create a backgrounded sub-agent card at all. + // mode:'all' — these tests exercise the *held* sub-agent path (the cancel-scoping + // fix). On 'full' sub-agents pass through (no pending to protect), so the top + // rung has to be on to create a backgrounded sub-agent card at all. const writeConfig = () => writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'all', adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, - approvals: { approvalGraceSec: 0, continueGraceSec: 0, continueWindowSec: 30, holdSubagents: true }, + approvals: { approvalGraceSec: 0, continueGraceSec: 0, continueWindowSec: 30 }, })); const fireSubAgentApproval = () => request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 'parent', toolName: 'Bash', input: { command: 'date' }, agentId: 'subA' }, @@ -115,11 +117,12 @@ describe('parent-session teardown must be agent-scoped (backgrounded sub-agent a await Promise.all([sub, stop]); }); - it('by default (holdSubagents off) a sub-agent approval passes through — no pending, tlive stays transparent', async () => { - // No holdSubagents in config → default false. A backgrounded sub-agent has no + it('on mode full a sub-agent approval passes through — no pending, tlive stays transparent', async () => { + // 'full' holds the main session only. A backgrounded sub-agent has no // parallel local dialog while a sync hook is held, so holding it would block it // with no fallback; instead tlive defers (shim {} → CC-native handling). writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'full', adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, approvals: { approvalGraceSec: 0, continueGraceSec: 0, continueWindowSec: 30 }, })); @@ -129,6 +132,30 @@ describe('parent-session teardown must be agent-scoped (backgrounded sub-agent a expect(res.kind === 'hook.permission.result' && res.decision).toBe('defer'); expect(h.permissionRouter.pendingCount()).toBe(0); }); + + it('picks up a posture change made AFTER boot — no daemon restart', async () => { + // The shim re-reads config.json on every hook, so the daemon must too: a + // `tlive mode all` at the terminal (or /mode from IM) has to change what the + // NEXT approval does. Before this, cfg was read once at boot and the sub-agent + // branch kept the boot-time posture forever. + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'full', + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + approvals: { approvalGraceSec: 0, continueGraceSec: 0, continueWindowSec: 30 }, + })); + h = await bootstrapDaemon({ home: tmp }); + + const passed = await request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 'parent', toolName: 'Bash', input: { command: 'date' }, agentId: 'subA' }, + { socketPath: sock, timeoutMs: 4000 }); + expect(passed.kind === 'hook.permission.result' && passed.decision).toBe('defer'); + + writeMode(tmp, 'all'); + + const held = fireSubAgentApproval(); + await until(() => { expect(h.permissionRouter.pendingCount()).toBe(1); }); + h.permissionRouter.cancel({ key: 'parent' }); + await held; + }); }); describe('continuation card has no on-card input box (quote-reply is the entry)', () => { diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 9df4c227..c7455041 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -28,7 +28,7 @@ import { ensureCodexAppServer, codexAppServerSockPath } from '../codex/spawn.js' import { connectCodexRpc } from '../codex/rpc.js'; import { startCompanion, type Companion } from '../codex/companion.js'; import { excerptForCard } from './excerpt.js'; -import { TURN_FINISHED_SENTINEL, effectiveMode } from '../hook/normalizer.js'; +import { TURN_FINISHED_SENTINEL, effectiveMode, type ShimMode } from '../hook/normalizer.js'; export interface DaemonHandle { shutdown(): Promise; @@ -236,6 +236,13 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { + try { return effectiveMode(loadConfig(opts.home).mode); } catch { return effectiveMode(cfg.mode); } + }; const desktop = opts.desktopNotifier ?? createDesktopNotifier({ action: { label: 'Open dashboard', @@ -475,9 +482,10 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise Math.max(cfg.approvals?.approvalGraceSec ?? 10, 0), - // Sub-agents pass through by default (tlive transparent — no tlive-introduced - // block); opt in to hold + remote-answer sub-agent approvals. See permission-router. - holdSubagents: () => cfg.approvals?.holdSubagents ?? false, + // Sub-agents pass through on 'full' (tlive transparent — no tlive-introduced + // block); the 'all' rung opts into holding + remotely answering them, at the + // cost of their terminal dialog. See permission-router's holdSubagents doc. + holdSubagents: () => currentMode() === 'all', // Opt-in: on a held approval's timeout, deny (→ turn ends → continue card can // redirect) instead of the default defer (→ CC-native local fallback). timeoutAction: () => cfg.approvals?.timeoutAction ?? 'defer', From 051320ec5379dac194fb8c50875d2d8a99c26991 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 14:31:29 +0800 Subject: [PATCH 07/26] feat(approvals): let a held request be handed back to the terminal on 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'. --- .../daemon/__tests__/inbound-handler.test.ts | 25 +++++++++++++++++++ .../__tests__/permission-router.test.ts | 17 ++++++++++++- src/kernel/daemon/bootstrap.ts | 9 ++++--- src/kernel/daemon/inbound-handler.ts | 12 +++++++++ src/kernel/daemon/permission-router.ts | 25 ++++++++++++++++--- 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/kernel/daemon/__tests__/inbound-handler.test.ts b/src/kernel/daemon/__tests__/inbound-handler.test.ts index ee22aa0f..4036e3f8 100644 --- a/src/kernel/daemon/__tests__/inbound-handler.test.ts +++ b/src/kernel/daemon/__tests__/inbound-handler.test.ts @@ -7,6 +7,7 @@ import { createEditQueue } from '../edit-queue.js'; import type { IncomingEnvelope, IMAdapter, OutgoingMessage } from '../../contracts/im-adapter.js'; import type { PermissionRouter } from '../permission-router.js'; import type { ContinueBroker } from '../../permission/continue-broker.js'; +import { STALE_CARD_NOTICE } from '../bootstrap.js'; const envelope = (over: Partial = {}): IncomingEnvelope => ({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'm1', text: '', ts: 0, ...over, @@ -716,3 +717,27 @@ describe('attachment injection', () => { expect(msgs).toHaveLength(0); }); }); + +describe('handback: callback (Answer at the terminal instead)', () => { + it('hands the request back and says so', async () => { + const handBack = vi.fn().mockReturnValue(true); + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ + imBy: () => makeAdapter(msgs), + permissionRouter: { handBack, answer: vi.fn() } as unknown as PermissionRouter, + })); + await h.handle(envelope({ text: 'handback:req-1' })); + expect(handBack).toHaveBeenCalledWith('req-1'); + expect(msgs[0].text).toMatch(/terminal/i); + }); + + it('a stale handback says so instead of going silent', async () => { + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ + imBy: () => makeAdapter(msgs), + permissionRouter: { handBack: vi.fn().mockReturnValue(false), answer: vi.fn() } as unknown as PermissionRouter, + })); + await h.handle(envelope({ text: 'handback:gone' })); + expect(msgs[0].text).toBe(STALE_CARD_NOTICE); + }); +}); diff --git a/src/kernel/daemon/__tests__/permission-router.test.ts b/src/kernel/daemon/__tests__/permission-router.test.ts index aba9c195..14379064 100644 --- a/src/kernel/daemon/__tests__/permission-router.test.ts +++ b/src/kernel/daemon/__tests__/permission-router.test.ts @@ -487,7 +487,8 @@ describe('sub-agent pass-through (tlive stays transparent for sub-agents by defa // Holding a sub-agent would therefore introduce a block CC never has on its own, // with no local fallback until the window times out. So the default is pass-through // (defer → shim outputs {} → CC-native: local dialog if interactive, else auto-deny). - // Remote sub-agent approval is opt-in via approvals.holdSubagents. + // Remote sub-agent approval is opt-in via the top posture rung (`mode: all`, + // see src/kernel/config/mode.ts). it('a sub-agent request (agentId present) passes through to defer by default — no card, no onPending, even when an answer surface exists', async () => { const send = vi.fn().mockResolvedValue(undefined); const pend: unknown[] = []; @@ -525,6 +526,20 @@ describe('sub-agent pass-through (tlive stays transparent for sub-agents by defa expect(res.decision).toBe('allow'); expect(send).not.toHaveBeenCalled(); }); + + it('handBack resolves a held request as handback (wire = defer, card = not a timeout)', async () => { + let id = ''; + const r = new PermissionRouter(base({ holdSubagents: () => true, sendToChat: async (_t: unknown, c: { requestId: string }) => { id = c.requestId; } })); + const p = r.requestPermission({ key: '/w', cwd: '/w', toolName: 'Bash', input: {}, agentId: 'agentA', timeoutSec: 60 }); + await new Promise((res) => setTimeout(res, 10)); + expect(r.handBack(id)).toBe(true); + expect((await p).decision).toBe('handback'); + }); + + it('handBack on an unknown/settled request reports false instead of inventing a decision', () => { + const r = new PermissionRouter(base({})); + expect(r.handBack('nope')).toBe(false); + }); }); describe('hasPendingFor — the daemon dedupe test for Notification(permission_prompt) (issue #49)', () => { diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index c7455041..478bef7c 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -452,7 +452,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise = { allow: 'Allowed', deny: 'Denied', defer: 'Timed out', local: 'Answered in terminal', gone: 'Session ended' }; + const OUTCOME: Record = { allow: 'Allowed', deny: 'Denied', defer: 'Timed out', local: 'Answered in terminal', gone: 'Session ended', handback: 'Handed back to the terminal' }; const permissionRouter = new PermissionRouter({ configuredChats, @@ -828,11 +828,12 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise: — a picked single-select option. Strict // digits-only match rejects '' and non-numeric input; `Number('')` is 0, diff --git a/src/kernel/daemon/permission-router.ts b/src/kernel/daemon/permission-router.ts index dd57be48..8dc5835a 100644 --- a/src/kernel/daemon/permission-router.ts +++ b/src/kernel/daemon/permission-router.ts @@ -8,10 +8,13 @@ import type { AskBatch } from '../permission/ask-renderer.js'; export interface AskContext { batch: AskBatch; input: unknown } /** 'local' = 用户在本地终端答的;IPC 层映射成 'defer'(shim 输出 pass-through {})—— 绝不 auto-allow。 + * 'handback' = 用户在卡上点了 "Answer at the terminal instead":同样映射成 + * 'defer',让 CC 当场把框建到终端里。与 'defer' 分开只为一件事—— + * 卡不能撒谎:超时是 "Timed out",这个是 "Handed back to the terminal"。 * 'gone' = 调用方(shim)在等待中异常死亡(会话被 Ctrl+C / 终端关闭)。终态, * **不产生任何 wire 输出**(shim 已死,本就送不出去),仅用于把卡 * 改写成 "Session ended" —— 卡必须说真话。 */ -export type Decision = 'allow' | 'deny' | 'defer' | 'local' | 'gone'; +export type Decision = 'allow' | 'deny' | 'defer' | 'local' | 'gone' | 'handback'; export interface PermChat { channel: string; chatId: string } export interface PermissionRouterDeps { @@ -78,7 +81,8 @@ export interface PermissionRouterDeps { * 无头 auto-deny)。理由:被后台化的子 agent 在同步 hook 被 hold 期间**没有** * 并行本地框(只有主会话有,先答先得 —— 真机实测),所以 hold 一个子 agent 会 * 引入一个 CC 自己根本不会有的阻塞,且超时前没有本地兜底。tlive 因此默认对 - * 子 agent 完全透明;想手机远程批子 agent 的人 opt-in 打开(approvals.holdSubagents)。 + * 子 agent 完全透明;想手机远程批子 agent 的人切到顶档 posture(`mode: all`, + * 见 src/kernel/config/mode.ts)opt-in 打开。 * * 机制(从 CC 二进制读出,2.1.216–2.1.220 一致):CC 给"能弹框的异步 agent" * 的权限上下文置 `awaitAutomatedChecksBeforeDialog`,于是它**先 await 完 @@ -163,7 +167,8 @@ export class PermissionRouter { // parallel local dialog until the window times out — a block CC never has on its // own. defer → shim {} → CC-native (local dialog if interactive, else auto-deny). // Runs AFTER the policy allow-check, so a safe/trusted sub-agent tool still - // auto-allows; opt-in holdSubagents makes sub-agent approvals remotely answerable. + // auto-allows; the top posture rung (`mode: all`) makes sub-agent approvals + // remotely answerable instead. if (opts.agentId && !(this.deps.holdSubagents?.() ?? false)) { outcome('subagent-passthrough'); // Handed back — but say what was handed back, while we still hold the @@ -328,6 +333,20 @@ export class PermissionRouter { return true; } + /** "Answer at the terminal instead" — hand ONE held request back to CC now + * instead of waiting out the window. The shim then writes {} and CC builds + * the dialog in the terminal (实测:defer 之后框立刻出现). Returns false when + * the card is already stale, so the caller can say so rather than going quiet. + * Never auto-allows: handing back is a pass-through, not a decision. */ + handBack(requestId: string): boolean { + const e = this.pending.get(requestId); + if (!e) return false; + this.pending.delete(requestId); + this.resolvedBy.set(requestId, 'handed-back'); + e.resolve({ decision: 'handback' }); + return true; + } + /** The user answered in the local terminal (PostToolUse / PermissionDenied / * UserPromptSubmit / Stop observed) — release matching pending shims. * `toolName` omitted = every pending request for the key. From e7ce8c35b377ca89850f7c8c710d290940808b23 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 14:35:00 +0800 Subject: [PATCH 08/26] feat(im): /mode sets the posture from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../daemon/__tests__/im-commands.test.ts | 17 ++++++++ .../daemon/__tests__/inbound-handler.test.ts | 37 ++++++++++++++++ src/kernel/daemon/im-commands.ts | 13 ++++++ src/kernel/daemon/inbound-handler.ts | 43 +++++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/src/kernel/daemon/__tests__/im-commands.test.ts b/src/kernel/daemon/__tests__/im-commands.test.ts index 7b28ad98..bca10a28 100644 --- a/src/kernel/daemon/__tests__/im-commands.test.ts +++ b/src/kernel/daemon/__tests__/im-commands.test.ts @@ -43,3 +43,20 @@ describe('parseImCommand (mute/help/trust/safe)', () => { expect(parseImCommand('/desktop off')).toEqual({ kind: 'unknown', name: 'desktop' }); }); }); + +describe('/mode — the posture ladder from the phone', () => { + it('recognizes every rung', () => { + expect(parseImCommand('/mode off')).toEqual({ kind: 'mode', mode: 'off' }); + expect(parseImCommand('/mode notify')).toEqual({ kind: 'mode', mode: 'notify' }); + expect(parseImCommand('/mode full')).toEqual({ kind: 'mode', mode: 'full' }); + expect(parseImCommand('/mode all')).toEqual({ kind: 'mode', mode: 'all' }); + }); + it('bare /mode (menu tap) or a bad arg → the ladder card, not "unknown"', () => { + expect(parseImCommand('/mode')).toEqual({ kind: 'mode-prompt' }); + expect(parseImCommand('/mode ALL')).toEqual({ kind: 'mode-prompt' }); + expect(parseImCommand('/mode maybe')).toEqual({ kind: 'mode-prompt' }); + }); + it('strips @botname', () => { + expect(parseImCommand('/mode@tlive_bot all')).toEqual({ kind: 'mode', mode: 'all' }); + }); +}); diff --git a/src/kernel/daemon/__tests__/inbound-handler.test.ts b/src/kernel/daemon/__tests__/inbound-handler.test.ts index 4036e3f8..04b59da4 100644 --- a/src/kernel/daemon/__tests__/inbound-handler.test.ts +++ b/src/kernel/daemon/__tests__/inbound-handler.test.ts @@ -36,6 +36,8 @@ const baseDeps = (over: Partial = {}): InboundHandlerDeps => setMuted: vi.fn(), setTrust: vi.fn(), setAutoApprove: vi.fn(), + getMode: () => 'full', + setMode: vi.fn(), addAllowTool: vi.fn(), resolveReply: () => undefined, sessionInfo: () => undefined, @@ -741,3 +743,38 @@ describe('handback: callback (Answer at the terminal instead)', () => { expect(msgs[0].text).toBe(STALE_CARD_NOTICE); }); }); + +describe('/mode from IM', () => { + it('a typed rung writes the posture and reports the transition', async () => { + const setMode = vi.fn(); + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'full', setMode })); + await h.handle(envelope({ text: '/mode all' })); + expect(setMode).toHaveBeenCalledWith('all'); + expect(msgs[0].text).toContain('full → all'); + }); + + it('the mode: button goes through the same setter', async () => { + const setMode = vi.fn(); + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'notify', setMode })); + await h.handle(envelope({ text: 'mode:full' })); + expect(setMode).toHaveBeenCalledWith('full'); + }); + + it('an unknown level in a callback is ignored, not applied', async () => { + const setMode = vi.fn(); + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter([]), setMode })); + await h.handle(envelope({ text: 'mode:root' })); + expect(setMode).not.toHaveBeenCalled(); + }); + + it('bare /mode replies with the four rungs and marks the current one', async () => { + const msgs: Array<{ kind: string; body?: string; buttons?: Array<{ id: string; label: string }> }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs as Array<{ kind: string; text?: string }>), getMode: () => 'full' })); + await h.handle(envelope({ text: '/mode' })); + expect(msgs[0].kind).toBe('card'); + expect(msgs[0].buttons?.map((b) => b.id)).toEqual(['mode:off', 'mode:notify', 'mode:full', 'mode:all']); + expect(msgs[0].buttons?.find((b) => b.id === 'mode:full')?.label).toMatch(/current/i); + }); +}); diff --git a/src/kernel/daemon/im-commands.ts b/src/kernel/daemon/im-commands.ts index 60942164..5547a104 100644 --- a/src/kernel/daemon/im-commands.ts +++ b/src/kernel/daemon/im-commands.ts @@ -8,6 +8,9 @@ // was dropped from IM for the same reason (you flip it AT the machine). // (/use was removed earlier — no workspace binding.) +import { MODES } from '../config/mode.js'; +import type { ShimMode } from '../hook/normalizer.js'; + export type ImCommand = | { kind: 'mute'; muted: boolean } | { kind: 'trust'; enabled: boolean } @@ -17,6 +20,14 @@ export type ImCommand = * replies with explicit on/off buttons (see runCommand). Explicit (not a blind * toggle) so a menu tap can never one-shot flip a dangerous state like /trust. */ | { kind: 'toggle-prompt'; which: 'mute' | 'trust' | 'safe' } + /** Posture ladder (off|notify|full|all) — the one command you fire from the + * phone when you realise you have left the keyboard. Unlike the runtime + * toggles it persists to config.json, because "I'm out" must survive a + * daemon restart. */ + | { kind: 'mode'; mode: ShimMode } + /** Bare /mode (menu tap) or an unknown level → show the ladder with a button + * per rung, same explicit-choice rule as toggle-prompt. */ + | { kind: 'mode-prompt' } | { kind: 'help' } | { kind: 'unknown'; name: string }; @@ -41,6 +52,8 @@ export function parseImCommand(text: string): ImCommand | null { if (arg === 'on') return { kind: 'safe', enabled: true }; if (arg === 'off') return { kind: 'safe', enabled: false }; return { kind: 'toggle-prompt', which: 'safe' }; + case 'mode': + return MODES.includes(arg as ShimMode) ? { kind: 'mode', mode: arg as ShimMode } : { kind: 'mode-prompt' }; case 'help': return { kind: 'help' }; default: diff --git a/src/kernel/daemon/inbound-handler.ts b/src/kernel/daemon/inbound-handler.ts index c7a35596..e7b3ce33 100644 --- a/src/kernel/daemon/inbound-handler.ts +++ b/src/kernel/daemon/inbound-handler.ts @@ -10,6 +10,8 @@ import type { AskFlow, AskStep } from './ask-flow.js'; import { parseImCommand } from './im-commands.js'; import { STALE_CARD_NOTICE } from './bootstrap.js'; import { SAFE_TOGGLE_MESSAGE } from '../permission/policy-engine.js'; +import { MODES, MODE_DESC } from '../config/mode.js'; +import type { ShimMode } from '../hook/normalizer.js'; export interface InboundHandlerDeps { senderGuard: SenderGuard; @@ -25,6 +27,10 @@ export interface InboundHandlerDeps { setTrust: (trusted: boolean) => void; /** Toggle `safe` auto-approve (auto-allow non-dangerous ops; `/safe on|off`). */ setAutoApprove: (safe: boolean) => void; + /** Current posture, for cards that must not lie about which rung is on. */ + getMode: () => ShimMode; + /** Persist a posture (config.json — the shim reads it on the next hook). */ + setMode: (mode: ShimMode) => void; /** Grant "always allow " (in-memory). */ addAllowTool: (tool: string) => void; /** AskUserQuestion progress for every pending request: the parsed batch, the @@ -72,6 +78,15 @@ function parseSetCallback(text: string): { which: 'mute' | 'trust' | 'safe'; on: return null; } +/** `mode:` — a rung button from the ladder card (or from a sub-agent + * card's one-tap posture switch). Unknown levels are ignored rather than + * guessed: a posture is not something to approximate. */ +function parseModeCallback(text: string): ShimMode | null { + if (!text.startsWith('mode:')) return null; + const level = text.slice('mode:'.length); + return MODES.includes(level as ShimMode) ? (level as ShimMode) : null; +} + export class InboundHandler { constructor(private deps: InboundHandlerDeps) {} @@ -206,6 +221,12 @@ export class InboundHandler { return; } + const mode = parseModeCallback(env.text); + if (mode) { + await this.runCommand(env, { kind: 'mode', mode }); + return; + } + const cmd = parseImCommand(env.text); if (cmd) { await this.runCommand(env, cmd); @@ -354,6 +375,27 @@ export class InboundHandler { this.deps.setAutoApprove(cmd.enabled); await this.reply(env, { kind: 'text', text: cmd.enabled ? SAFE_TOGGLE_MESSAGE.on : SAFE_TOGGLE_MESSAGE.off }); return; + case 'mode': { + const prev = this.deps.getMode(); + this.deps.setMode(cmd.mode); + await this.reply(env, { + kind: 'text', + text: prev === cmd.mode + ? `Mode unchanged — ${MODE_DESC[cmd.mode]}` + : `Mode: ${prev} → ${cmd.mode}\n${MODE_DESC[cmd.mode]}`, + }); + return; + } + case 'mode-prompt': { + const cur = this.deps.getMode(); + await this.reply(env, { + kind: 'card', + title: 'tlive · /mode', + body: [`Current: **${cur}**`, '', ...MODES.map((m) => `\`${m}\` — ${MODE_DESC[m].replace(`${m} — `, '')}`)].join('\n'), + buttons: MODES.map((m) => ({ id: `mode:${m}`, label: m === cur ? `${m} (current)` : m })), + }); + return; + } case 'toggle-prompt': { // Bare /mute /trust /safe (a menu tap sends the command with no arg). Reply // with explicit on/off buttons instead of "Unknown command" — the tap is now @@ -381,6 +423,7 @@ export class InboundHandler { '`/mute on|off` — mute / unmute IM notifications (on = quiet)', '`/trust on|off` — pause / resume approvals (auto-allow all)', '`/safe on|off` — auto-allow routine ops, still ask for dangerous / unknown', + '`/mode off|notify|full|all` — posture: how much tlive intercepts (bare `/mode` shows the ladder)', '`/help` — this help', '', '**Reply to a session** — quote-reply its message and your text is injected into that terminal (needs a `tlive run` wrapper). With a single active session, just send text.', From 9a1f72d62e1040840f102526d01913a4390d9022 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 14:50:12 +0800 Subject: [PATCH 09/26] fix(mode): document currentMode's missing-file path as intentional, pin it with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../daemon/__tests__/session-ipc.test.ts | 27 ++++++++++++++++++- src/kernel/daemon/bootstrap.ts | 12 ++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/kernel/daemon/__tests__/session-ipc.test.ts b/src/kernel/daemon/__tests__/session-ipc.test.ts index c31ef138..b849266b 100644 --- a/src/kernel/daemon/__tests__/session-ipc.test.ts +++ b/src/kernel/daemon/__tests__/session-ipc.test.ts @@ -1,6 +1,6 @@ // src/kernel/daemon/__tests__/session-ipc.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { bootstrapDaemon, type DaemonHandle } from '../bootstrap'; @@ -156,6 +156,31 @@ describe('parent-session teardown must be agent-scoped (backgrounded sub-agent a h.permissionRouter.cancel({ key: 'parent' }); await held; }); + + it('config.json deleted after boot resolves like a fresh install (notify), not the boot-time mode', async () => { + // Deleting config.json is not "config unreadable, so guess the last known + // posture" — loadConfig's DEFAULT branch for a missing file never throws, + // so currentMode() takes the SAME path the hook shim's readMode() does for + // the identical (absent) file and lands on the same 'notify' default — + // daemon and shim can never disagree over whether the file exists. This + // request only reaches the daemon because it bypasses the shim (direct + // IPC): in the real `tlive hook` path, modeShortCircuit('notify', …) would + // already have short-circuited the permission-request to `{}` before any + // IPC was even attempted, so this daemon-side value is unobservable there. + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'all', + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + approvals: { approvalGraceSec: 0, continueGraceSec: 0, continueWindowSec: 30 }, + })); + h = await bootstrapDaemon({ home: tmp }); + + rmSync(join(tmp, 'config.json')); + + const res = await request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 'parent', toolName: 'Bash', input: { command: 'date' }, agentId: 'subA' }, + { socketPath: sock, timeoutMs: 4000 }); + expect(res.kind === 'hook.permission.result' && res.decision).toBe('defer'); + expect(h.permissionRouter.pendingCount()).toBe(0); + }); }); describe('continuation card has no on-card input box (quote-reply is the entry)', () => { diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 478bef7c..5a128d22 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -239,7 +239,17 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { try { return effectiveMode(loadConfig(opts.home).mode); } catch { return effectiveMode(cfg.mode); } }; From c9e82e02b1fb42c3c1fa69879069be8023eb40cd Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 14:50:50 +0800 Subject: [PATCH 10/26] test(im): strengthen /mode tests so the current-rung marker can't be a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../daemon/__tests__/inbound-handler.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/kernel/daemon/__tests__/inbound-handler.test.ts b/src/kernel/daemon/__tests__/inbound-handler.test.ts index 04b59da4..5e2331f7 100644 --- a/src/kernel/daemon/__tests__/inbound-handler.test.ts +++ b/src/kernel/daemon/__tests__/inbound-handler.test.ts @@ -746,12 +746,15 @@ describe('handback: callback (Answer at the terminal instead)', () => { describe('/mode from IM', () => { it('a typed rung writes the posture and reports the transition', async () => { + // getMode deliberately differs from baseDeps' own default ('full') so a + // hardcoded `prev` cannot coincidentally match — the assertion below only + // passes if runCommand actually reads this.deps.getMode(). const setMode = vi.fn(); const msgs: Array<{ kind: string; text?: string }> = []; - const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'full', setMode })); + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'notify', setMode })); await h.handle(envelope({ text: '/mode all' })); expect(setMode).toHaveBeenCalledWith('all'); - expect(msgs[0].text).toContain('full → all'); + expect(msgs[0].text).toContain('notify → all'); }); it('the mode: button goes through the same setter', async () => { @@ -777,4 +780,15 @@ describe('/mode from IM', () => { expect(msgs[0].buttons?.map((b) => b.id)).toEqual(['mode:off', 'mode:notify', 'mode:full', 'mode:all']); expect(msgs[0].buttons?.find((b) => b.id === 'mode:full')?.label).toMatch(/current/i); }); + + it('the "current" marker tracks the injected getMode, not a fixed rung — a different current moves the marker to a different button', async () => { + // getMode returns 'notify' here, NOT 'full' (baseDeps' own default), so this + // only passes if the mode-prompt branch actually calls this.deps.getMode() + // rather than being hardcoded to whatever the default fixture happens to be. + const msgs: Array<{ kind: string; body?: string; buttons?: Array<{ id: string; label: string }> }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs as Array<{ kind: string; text?: string }>), getMode: () => 'notify' })); + await h.handle(envelope({ text: '/mode' })); + expect(msgs[0].buttons?.find((b) => b.id === 'mode:notify')?.label).toMatch(/current/i); + expect(msgs[0].buttons?.find((b) => b.id === 'mode:full')?.label).not.toMatch(/current/i); + }); }); From f397f85db51fe06b3f9a684a4d4f66a70eb5fa91 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 15:03:59 +0800 Subject: [PATCH 11/26] feat(cards): one-tap posture switch on sub-agent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/kernel/daemon/__tests__/bootstrap.test.ts | 66 +++++++++++++++++++ src/kernel/daemon/bootstrap.ts | 20 +++++- src/kernel/daemon/permission-router.ts | 4 +- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/kernel/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index b102f7a9..0cabaa4e 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -1284,3 +1284,69 @@ describe('permission_prompt forwarding — the notify-mode / immediate-defer not expect(sent).toHaveLength(0); }); }); + +describe('sub-agent card affordances', () => { + it('on mode all, a held sub-agent card offers a way back to the terminal and a posture switch', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'all', + web: { enabled: false }, + approvals: { approvalGraceSec: 0 }, + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + })); + const sent: OutgoingMessage[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [interactiveAdapter('telegram', sent)] }); + const pending = held(request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 's1', toolName: 'Bash', input: { command: 'date' }, agentId: 'agentA' }, + { socketPath: daemonSocketPath(tmp), timeoutMs: 4000 })); + await waitForSent(sent); + + const card = sent[0] as Extract; + const ids = card.buttons!.map((b) => b.id); + expect(ids.some((i) => i.startsWith('handback:'))).toBe(true); + expect(ids).toContain('mode:full'); + + h.permissionRouter.cancel({ key: 's1' }); + await pending; + }); + + it('on mode full, the sub-agent pass-through notice offers to start holding them', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'full', + web: { enabled: false }, + approvals: { approvalGraceSec: 0 }, + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + })); + const sent: OutgoingMessage[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [interactiveAdapter('telegram', sent)] }); + const r = await request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 's1', toolName: 'Read', input: { file_path: '/etc/hosts' }, agentId: 'agentA' }, + { socketPath: daemonSocketPath(tmp), timeoutMs: 4000 }); + expect(r.kind === 'hook.permission.result' && r.decision).toBe('defer'); // still transparent + await waitForSent(sent); + + const notice = sent[0] as Extract; + expect(notice.buttons?.map((b) => b.id)).toEqual(['mode:all']); + // The notice must NOT offer Allow/Deny — that dialog can only be answered at + // the keyboard, and an affordance that cannot work is worse than none. + expect(notice.buttons?.some((b) => b.id.startsWith('approve:'))).toBe(false); + }); + + it('a main-session card keeps exactly its old button set', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'full', + web: { enabled: false }, + approvals: { approvalGraceSec: 0 }, + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + })); + const sent: OutgoingMessage[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [interactiveAdapter('telegram', sent)] }); + const pending = held(request({ kind: 'hook.permission.request', cwd: '/proj', sessionId: 's1', toolName: 'Bash', input: { command: 'date' } }, + { socketPath: daemonSocketPath(tmp), timeoutMs: 4000 })); + await waitForSent(sent); + + const card = sent[0] as Extract; + const ids = card.buttons!.map((b) => b.id.split(':')[0]); + expect(ids).toEqual(['approve', 'deny', 'allowtool', 'pause']); + + h.permissionRouter.cancel({ key: 's1' }); + await pending; + }); +}); diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 5a128d22..91b518f6 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -29,6 +29,7 @@ import { connectCodexRpc } from '../codex/rpc.js'; import { startCompanion, type Companion } from '../codex/companion.js'; import { excerptForCard } from './excerpt.js'; import { TURN_FINISHED_SENTINEL, effectiveMode, type ShimMode } from '../hook/normalizer.js'; +import { writeMode } from '../config/mode.js'; export interface DaemonHandle { shutdown(): Promise; @@ -369,7 +370,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise void }, + msg: { title?: string; body?: string; text?: string; requestId?: string; toolName?: string; cwd?: string; ask?: AskContext; agentId?: string; buttons?: Array<{ id: string; label: string }>; onSent?: (s: { channel: string; messageId: string }) => void }, ): Promise => { const adapter = (opts.imAdapters ?? []).find((a) => a.channel === target.channel); if (!adapter) return; @@ -389,7 +390,12 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { @@ -513,6 +523,10 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { const id = passthruKey(key, agentId, toolName); const list = passthruNotices.get(id) ?? []; @@ -1076,6 +1090,8 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { const id = latestContinueId; latestContinueId = null; return id; }, setMuted: (m: boolean) => runtimeSet('mute', m), setTrust: (t: boolean) => runtimeSet('trust', t), + getMode: () => currentMode(), + setMode: (m) => { writeMode(opts.home, m); logJson('mode.set', { mode: m }); }, setAutoApprove: (safe: boolean) => runtimeSet('safe', safe), addAllowTool: (tool: string) => { policyState.allowTools?.add(tool); }, askFlow, diff --git a/src/kernel/daemon/permission-router.ts b/src/kernel/daemon/permission-router.ts index 8dc5835a..4beca346 100644 --- a/src/kernel/daemon/permission-router.ts +++ b/src/kernel/daemon/permission-router.ts @@ -24,7 +24,7 @@ export interface PermissionRouterDeps { * (Task 10) additionally selects the checkbox/Submit(N)/Skip layout over * the single-pick numbered buttons — both are opaque booleans/arrays to * this vendor-neutral layer, it never inspects toolName itself. */ - sendToChat: (target: PermChat, card: { title: string; body: string; requestId: string; toolName: string; cwd: string; ask?: AskContext }) => Promise; + sendToChat: (target: PermChat, card: { title: string; body: string; requestId: string; toolName: string; cwd: string; ask?: AskContext; agentId?: string }) => Promise; /** `key` — the session's registry identity (see requestPermission's `key` opt), NOT the real cwd. * Mutes the IM card ONLY (desktop toast / dashboard stay live) — it no longer * defers the whole approval on its own (see requestPermission). */ @@ -249,7 +249,7 @@ export class PermissionRouter { // exactly how an oversized Telegram callback_data (see toolNameFor) // went unnoticed. Still non-fatal: the local dialog is unaffected and // the other targets/dashboard may well have gone out. - void this.deps.sendToChat(t, { title, body, requestId, toolName: opts.toolName, cwd: opts.key, ...(ask ? { ask } : {}) }) + void this.deps.sendToChat(t, { title, body, requestId, toolName: opts.toolName, cwd: opts.key, ...(ask ? { ask } : {}), ...(opts.agentId ? { agentId: opts.agentId } : {}) }) .catch((e: unknown) => { this.deps.log?.('permission.card.undelivered', { ...who, requestId, channel: t.channel, error: e instanceof Error ? e.message : String(e), From 4071c6935844751b77b2034f83e96d8a7e75ed63 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 15:20:49 +0800 Subject: [PATCH 12/26] docs(mode): document the four-rung ladder and the sub-agent card buttons --- KERNEL.md | 21 +++--- README.md | 75 ++++++++++--------- README_CN.md | 55 +++++++------- docs/commands.md | 43 ++++++++--- plugins/.content-lock.json | 4 +- .../plugins/tlive/.claude-plugin/plugin.json | 2 +- .../claude/plugins/tlive/commands/setup.md | 6 +- .../claude/plugins/tlive/commands/status.md | 7 +- .../plugins/tlive/skills/tlive/SKILL.md | 38 ++++++---- .../plugins/tlive/.codex-plugin/plugin.json | 2 +- .../codex/plugins/tlive/skills/tlive/SKILL.md | 38 ++++++---- src/adapters/im/telegram.ts | 1 + src/cli/main.ts | 7 +- 13 files changed, 178 insertions(+), 121 deletions(-) diff --git a/KERNEL.md b/KERNEL.md index b478d545..deb02c45 100644 --- a/KERNEL.md +++ b/KERNEL.md @@ -5,10 +5,10 @@ > `tests/contract/` 锁定。改动任一接口 = breaking change = bump major。 > > **默认姿态 `notify`**:只监看 / 通知,shim 把每个 `PermissionRequest` 短路成 -> `{}`,tlive 物理上不 hold 任何审批。下文"审批"一节描述的 gating 只在 -> **`mode: full`**(远程审批 opt-in)下发生;`mode: off` 则每个 hook 都 no-op。 -> mode 语义见 `README.md`,由 `normalizer.ts` 的 `effectiveMode` -> 单点决定(notify 默认)。 +> `{}`,tlive 物理上不 hold 任何审批。下文"审批"一节描述的 gating 在 +> **`mode: full`**(远程审批 opt-in)及 **`mode: all`**(连子代理也 hold 住) +> 下发生;`mode: off` 则每个 hook 都 no-op。mode 语义见 `README.md`,由 +> `normalizer.ts` 的 `effectiveMode` 单点决定(notify 默认)。 > > v1.0 的 SDK-driver 冻结面(`RuntimeAdapter` / MCP 三工具等)已在 v2 移除—— > tlive 不再驱动/拥有会话。 @@ -98,11 +98,12 @@ continueDecisionOut(reply: string | null): object // reply → {decision:'bloc Claude 的原始 hook JSON 在这里归一。加一个新 AI runtime = 把它的 hook 事件 映射进这套归一模型(hook 式集成)或走 companion 式集成(见下),kernel 不变。 -**审批(Claude Code)**(下述 gating 仅 `mode: full`;默认 `notify` 下 shim -把 `PermissionRequest` 短路成 `{}`,以下一律不发生)。**带 `agent_id` 的 -子代理请求默认透传**(→`{}`,交 CC 原生;被 hold 的子代理没有并行本地框可 -兜底)——opt-in `approvals.holdSubagents:true` 才 hold 住等远程答;`safe`/ -`trust` 的自动放行在此闸之前,不受影响。gating 走 `PermissionRequest` hook +**审批(Claude Code)**(下述主会话 gating 在 `mode: full`/`all` 下发生; +默认 `notify` 下 shim 把 `PermissionRequest` 短路成 `{}`,以下一律不发生)。 +**带 `agent_id` 的子代理请求默认透传**(→`{}`,交 CC 原生;被 hold 的子代理 +没有并行本地框可兜底)——只有 `mode: all` 才 hold 住等远程答(第四档,取代 +已删除的 `approvals.holdSubagents`);`safe`/`trust` 的自动放行在此闸之前, +不受影响。gating 走 `PermissionRequest` hook ——它与本地权限对话**并行**(先答先得),窗口默认顶格(`approvals.windowSec`,默认 86200s ≈24h,clamp 60~86200,**两家共用**;hooks.json `timeout: 86400`,shim IPC=窗+100s,daemon clamp 24h。超时 ≠ 拒绝——本地框永不超时仍在等,短窗口 @@ -261,7 +262,7 @@ File: `src/kernel/contracts/cli-surface.ts` ``` 核心 8: setup, start, stop, status, logs, run, url, hook -加法命令: mode (off|notify|full 姿态,持久化到 config) +加法命令: mode (off|notify|full|all 姿态,持久化到 config) mute, trust, safe, desktop (on|off 运行时开关,与 IM 命令同一 setter) ``` diff --git a/README.md b/README.md index ebfba9a5..4c1bad3d 100644 --- a/README.md +++ b/README.md @@ -77,21 +77,29 @@ continue" line makes the distinction moot for what you actually do. ## What's in the box -- **Posture — `notify` (default) / `full` / `off`.** One coarse switch that - sits above every fine toggle. **`notify`** (default) watches and notifies - but the shim can never hold or block an approval — every prompt stays 100% - native (your local terminal dialog, or CC's own auto-deny when headless); - when a prompt is waiting at the terminal you still get told (desktop toast, - read-only dashboard card, graced IM text — pointers back to the terminal, - never a decision). - **`full`** turns on remote approval: tlive holds each tool call so you can - answer it from IM / desktop / dashboard (everything the *Approvals* bullet - below describes). **`off`** makes every hook a no-op (kill switch — no - gating, notifications, monitoring, or daemon autostart). Flip it live with - `tlive mode off|notify|full`; the shim re-reads config on the next hook, so - no restart or new session is needed, and `tlive status` shows the effective - mode. Remote approval is opt-in by design — a freshly-installed tool must - never be able to silently hang a workflow. +- **Posture — `off` / `notify` (default) / `full` / `all`.** One coarse + switch that sits above every fine toggle, in escalation order (how much + tlive intercepts). **`off`** makes every hook a no-op (kill switch — no + gating, notifications, monitoring, or daemon autostart). **`notify`** + (default) watches and notifies but the shim can never hold or block an + approval — every prompt stays 100% native (your local terminal dialog, or + CC's own auto-deny when headless); when a prompt is waiting at the terminal + you still get told (desktop toast, read-only dashboard card, graced IM + text — pointers back to the terminal, never a decision). **`full`** turns + on remote approval for the main session: tlive holds each tool call so you + can answer it from IM / desktop / dashboard (everything the *Approvals* + bullet below describes), in parallel with the terminal dialog — first + answer wins. Sub-agent prompts still pass through to the terminal. + **`all`** holds sub-agent approvals too — the trade is that **a held + sub-agent has no terminal dialog until the window ends**, because Claude + Code awaits the hook before deciding whether to build one; use it only when + nobody is at the keyboard, and `tlive mode full` goes back. Flip postures + live with `tlive mode off|notify|full|all` (or `/mode` from IM — a bare + `/mode` replies with a card listing the ladder and marking the current + rung); the shim re-reads config on the next hook, so no restart or new + session is needed, and `tlive status` shows the effective mode. Remote + approval is opt-in by design — a freshly-installed tool must never be able + to silently hang a workflow. - **Approvals** *(remote approval — `mode: full`)* — a tool call that needs approval is held so you can answer it from IM buttons or the web card: - **Parallel, first-answer-wins** — on Claude Code the `PermissionRequest` @@ -112,12 +120,14 @@ continue" line makes the distinction moot for what you actually do. - **Power toggles** — **"Always allow \"** grants a per-tool pass (in-memory, cleared on restart; on Claude Code it answers the native dialog for you remotely); `/trust on|off` pauses approvals entirely. - - **Sub-agents pass through by default** — tlive returns `{}` and lets CC - handle a backgrounded/async sub-agent natively, so its terminal dialog still - appears exactly as it would without tlive. Holding one instead would remove - that dialog (CC resolves an async agent's hook *before* it builds the box), - leaving remote as the only answer path — that is what - `approvals.holdSubagents: true` opts into. + - **Sub-agents pass through by default** — on `full`, tlive returns `{}` and + lets CC handle a backgrounded/async sub-agent natively, so its terminal + dialog still appears exactly as it would without tlive. **`tlive mode + all`** holds sub-agent approvals too, but the trade is real: CC resolves + an async agent's hook *before* it decides whether to build a dialog, so a + held sub-agent has **no** terminal box until the window ends — remote + becomes the only answer path. Worth it only when nobody is at the + keyboard; `tlive mode full` goes back. - **Answer `AskUserQuestion` from IM or the dashboard (Claude Code only)** — CC fires a `PermissionRequest` for its own question tool; tlive relays it as a single-select or multi-select card (checkboxes, a live `Submit (N)` count, @@ -289,7 +299,7 @@ tlive status health, effective mode, web URLs + QR, config paths tlive logs [-f] tail the daemon log tlive run … wrap a process: local terminal + web terminal tlive url print the dashboard URL + QR (when a full-screen app hid the run banner) -tlive mode off|notify|full set posture (see "What's in the box"); takes effect on the next hook +tlive mode off|notify|full|all set posture (see "What's in the box"); takes effect on the next hook tlive hook hook shim (called by Claude Code, not by you; Codex has no hooks — see the app-server companion) ``` @@ -300,9 +310,10 @@ frozen surface (locked by `tests/contract/`); `mode` and the runtime toggles IM commands: `/mute on|off` (silence IM notifications), `/trust on|off` (pause approvals — auto-allow everything), `/safe on|off` (auto-allow routine ops), -`/help`. Tapping a bare command from the client's command menu replies with -on/off buttons instead of an error. Quote-reply any session message to type -into that session. +`/mode off|notify|full|all` (set posture; a bare `/mode` replies with the +ladder), `/help`. Tapping a bare command from the client's command menu +replies with on/off buttons instead of an error. Quote-reply any session +message to type into that session. ## Config (`~/.tlive/config.json`) @@ -311,8 +322,10 @@ into that session. ```jsonc { - // posture: "off" | "notify" (default) | "full" (remote approval on). - // Also set live with `tlive mode …`; unset/unknown falls back to notify. + // posture: "off" | "notify" (default) | "full" | "all" (remote approval; + // "all" also holds sub-agent prompts — see "What's in the box"). Also set + // live with `tlive mode …` or `/mode` from IM; unset/unknown falls back to + // notify. "mode": "notify", "adapters": { "telegram": { "token": "…", "chatIdAllowList": ["123"] }, @@ -353,14 +366,6 @@ into that session. // /safe on|off. Never crosses the danger floor — only /trust on auto-allows // dangerous ops. // "autoApprove": "readonly", - // hold a backgrounded/async sub-agent's approval for a remote answer too. - // Default false, and changing it COSTS YOU THE TERMINAL: for an async agent - // CC waits for the hook's decision *before* building the dialog, so a held - // sub-agent request has no local box to fall back on — IM/dashboard becomes - // the only way to answer it, and an unanswered one waits out the whole - // window. Sub-agents have run in the background by default since Claude Code - // 2.1.198, so this applies to most of them. Only relevant in mode: full. - "holdSubagents": false, // what a HELD approval does when its window times out with nobody // answering: "defer" (default) → pass-through {} (CC-native fallback); // "deny" → deny with a "timed out" message so the turn can end and the diff --git a/README_CN.md b/README_CN.md index 6a1f4d68..95887c40 100644 --- a/README_CN.md +++ b/README_CN.md @@ -68,16 +68,21 @@ IM 消息带 `label · ` 前缀(会话目录名),但不再用图标区分包装/ ## 功能一览 -- **姿态 —— `notify`(默认)/ `full` / `off`。** 一个坐在所有细旋钮之上的 - 粗开关。**`notify`**(默认)只监看 + 通知,但 shim 物理上无法 hold 或 - 阻塞任何审批——每个提示都保持 100% 原生(你本地终端的对话框,或无头时 - CC 自己的 auto-deny);提示在终端等你时仍会提醒(桌面通知、dashboard - 只读卡、grace 后的 IM 文本——只是指回终端的路标,绝不代答)。 - **`full`** 开启远程审批:tlive hold 住每个工具调用, - 让你在 IM / 桌面 / dashboard 上作答(即下方*审批*那条描述的一切)。 - **`off`** 让每个 hook 都成 no-op(kill switch——不 gating、不通知、不监看、 - 不懒启动 daemon)。用 `tlive mode off|notify|full` 实时切换;shim 每个 hook - 都重读 config,无需重启也无需新会话,`tlive status` 会显示当前生效的 mode。 +- **姿态 —— `off` / `notify`(默认)/ `full` / `all`。** 一个坐在所有细旋钮 + 之上的粗开关,按"tlive 拦多少"升序排列。**`off`** 让每个 hook 都成 + no-op(kill switch——不 gating、不通知、不监看、不懒启动 daemon)。 + **`notify`**(默认)只监看 + 通知,但 shim 物理上无法 hold 或阻塞任何 + 审批——每个提示都保持 100% 原生(你本地终端的对话框,或无头时 CC 自己的 + auto-deny);提示在终端等你时仍会提醒(桌面通知、dashboard 只读卡、grace + 后的 IM 文本——只是指回终端的路标,绝不代答)。**`full`** 为主会话开启 + 远程审批:tlive hold 住每个工具调用,让你在 IM / 桌面 / dashboard 上作答 + (即下方*审批*那条描述的一切),与本地终端对话框并行竞速——先答先得; + 子代理的提示仍照常透传给终端。**`all`** 连子代理审批也一并 hold 住—— + 代价是**被 hold 的子代理在窗口结束前没有终端框**,因为 Claude Code 会先 + 等 hook 决定要不要建框——只在没人守着键盘时才划算,`tlive mode full` + 可以切回去。用 `tlive mode off|notify|full|all` 实时切换姿态(或在 IM 里 + 发 `/mode`——裸发也会回一张列出梯子并标出当前档的卡);shim 每个 hook 都 + 重读 config,无需重启也无需新会话,`tlive status` 会显示当前生效的 mode。 远程审批设计成 opt-in——刚装好的工具绝不该能悄悄挂起你的工作流。 - **审批** *(远程审批 —— `mode: full`)* —— 需要审批的工具调用会被 hold 住, 让你从 IM 按钮或 web 卡作答: @@ -94,10 +99,12 @@ IM 消息带 `label · ` 前缀(会话目录名),但不再用图标区分包装/ expandable 折叠——用较新 Telegram 客户端渲染最佳。 - **高危开关** —— **"总是允许 \<工具\>"** 按工具放行(内存态、重启清零; Claude Code 上远程替你点掉原生对话);`/trust on|off` 整体暂停审批。 - - **子代理默认透传** —— tlive 返回 `{}` 交给 CC 原生处理,所以它的终端框和 - 没装 tlive 时完全一样地弹出。反过来 hold 住它就会抹掉那个框(对异步 agent, - CC 会在建框之前先解析 hook),只剩远程一条答复路径 —— - `approvals.holdSubagents: true` 就是选择这个代价。 + - **子代理默认透传** —— 在 `full` 下,tlive 返回 `{}` 交给 CC 原生处理, + 所以它的终端框和没装 tlive 时完全一样地弹出。**`tlive mode all`** 连 + 子代理审批也一并 hold 住,但代价是真实的:对异步 agent,CC 会在决定 + 要不要建框之前先解析 hook,所以被 hold 的子代理在窗口结束前**没有** + 终端框——远程成了唯一答复路径。只有没人守着键盘时才划算, + `tlive mode full` 可以切回去。 - **远程回答 `AskUserQuestion`(仅 Claude Code)** —— CC 为自己的提问工具 fire `PermissionRequest`;tlive 把它转成单选或多选卡(复选框、实时 `Submit (N)` 计数、`Skip`)而非 Allow/Deny,IM 和 dashboard 会话卡都能答。 @@ -238,7 +245,7 @@ tlive status 健康态、当前生效 mode、web 地址 + 二维码、 tlive logs [-f] 看 daemon 日志 tlive run … 包装进程:本地终端 + web 终端 tlive url 打印 dashboard 地址 + 二维码(全屏应用盖住 run banner 时用) -tlive mode off|notify|full 设置姿态(见"功能一览");下一个 hook 即生效 +tlive mode off|notify|full|all 设置姿态(见"功能一览");下一个 hook 即生效 tlive hook hook shim(Claude Code 调用,不是给你用的; Codex 没有 hook——见 app-server companion) ``` @@ -248,9 +255,10 @@ tlive hook hook shim(Claude Code 调用,不是给你用的; (`on|off`)、`desktop`(`on|off`)是加法命令。 IM 命令:`/mute on|off`(静音 IM 通知)、`/trust on|off`(暂停审批——全部 -自动放行)、`/safe on|off`(自动放行日常操作)、`/help`。在客户端命令菜单里 -点一下裸命令,会回一组 on/off 按钮而不是报错。引用任意会话消息回复 = -打字进那个会话。 +自动放行)、`/safe on|off`(自动放行日常操作)、`/mode off|notify|full|all` +(设置姿态;裸发 `/mode` 会回梯子卡)、`/help`。在客户端命令菜单里点一下裸 +命令,会回一组 on/off 按钮而不是报错。引用任意会话消息回复 = 打字进那个 +会话。 ## 配置(`~/.tlive/config.json`) @@ -259,8 +267,9 @@ IM 命令:`/mute on|off`(静音 IM 通知)、`/trust on|off`(暂停审批—— ```jsonc { - // 姿态:"off" | "notify"(默认)| "full"(开远程审批)。 - // 也可用 `tlive mode …` 实时设置;未设 / 未知值都回落 notify。 + // 姿态:"off" | "notify"(默认)| "full" | "all"(远程审批,"all" 连子代理 + // 提示也一并 hold 住——见"功能一览")。也可用 `tlive mode …` 或 IM 里的 + // `/mode` 实时设置;未设 / 未知值都回落 notify。 "mode": "notify", "adapters": { "telegram": { "token": "…", "chatIdAllowList": ["123"] }, @@ -294,12 +303,6 @@ IM 命令:`/mute on|off`(静音 IM 通知)、`/trust on|off`(暂停审批—— // 用于自主/agent 驱动场景减少卡量。可用 /safe on|off 实时切换。 // 绝不越过危险地板——只有 /trust on 才会自动放行危险操作。 // "autoApprove": "readonly", - // 是否连后台/异步子代理的审批也 hold 住等远程作答。默认 false, - // **改成 true 会让你失去终端**:对异步 agent,CC 会在建框之前先等 hook 的 - // 决定,所以被 hold 的子代理请求没有本地框可兜底 —— IM/dashboard 成为唯一 - // 答复路径,没人应答就会耗掉整个窗口。自 Claude Code 2.1.198 起子代理默认 - // 后台运行,所以这适用于绝大多数子代理。仅在 mode: full 下有意义。 - "holdSubagents": false, // 一个被 hold 的审批在窗口超时、无人应答时怎么办: // "defer"(默认)→ 透传 {}(回落 CC 原生); // "deny" → 带一句"已超时"的拒绝,好让 turn 结束、续跑卡改道 agent。 diff --git a/docs/commands.md b/docs/commands.md index 9e30a308..e27a20c5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ > **v2 CLI surface.** The frozen core is exactly: > `setup`, `start`, `stop`, `status`, `logs`, `run`, `url`, `hook`. -> Additive since: `mode` (posture: off / notify / full) and the runtime +> Additive since: `mode` (posture: off / notify / full / all) and the runtime > toggles `mute`, `trust`, `safe`, `desktop` (`on|off`, same setters as the > in-chat IM commands). > @@ -31,9 +31,9 @@ work). Waits up to 2 s for the event loop to drain before forcing exit. Show whether the daemon is running, its uptime, PID, and the configured IM adapters, plus the web URLs and QR code. Replaces the removed `doctor` -subcommand. Also prints the effective **`mode:`** line (off / notify / full) — -the first thing to check when an approval card never arrives (in the default -`notify` mode tlive never sends one; see `tlive mode`). +subcommand. Also prints the effective **`mode:`** line (off / notify / full / +all) — the first thing to check when an approval card never arrives (in the +default `notify` mode tlive never sends one; see `tlive mode`). ### `tlive logs [N] [-f | --follow]` @@ -101,20 +101,40 @@ want the address again — run it in another terminal. ## Posture & runtime toggles -### `tlive mode off|notify|full` +### `tlive mode off|notify|full|all` -Set tlive's **posture** — one coarse switch above every fine toggle. Persisted -to `~/.tlive/config.json` and read by the hook shim on every event, so it takes +Set tlive's **posture** — one coarse switch above every fine toggle, in +escalation order (how much tlive intercepts). Persisted to +`~/.tlive/config.json` and read by the hook shim on every event, so it takes effect on the **next hook** — no daemon restart, no new session. | Mode | What it does | |---|---| -| `notify` (default) | Watch + notify only. The shim short-circuits every `PermissionRequest` to a pass-through `{}` — tlive **can never hold or block an approval**; every prompt stays 100% native. You still get told when a prompt is waiting at the terminal (desktop toast, read-only dashboard card, graced IM text — pointers only, never a decision). Monitoring, turn-finished / waiting notifications, and reply-to-continue all still work. | -| `full` | Remote approval ON — tlive holds each tool call so you can Allow/Deny it from IM / desktop / dashboard. The previous always-on behaviour. | | `off` | Every hook is a no-op — no gating, notifications, monitoring, or daemon autostart (kill switch). | +| `notify` (default) | Watch + notify only. The shim short-circuits every `PermissionRequest` to a pass-through `{}` — tlive **can never hold or block an approval**; every prompt stays 100% native. You still get told when a prompt is waiting at the terminal (desktop toast, read-only dashboard card, graced IM text — pointers only, never a decision). Monitoring, turn-finished / waiting notifications, and reply-to-continue all still work. | +| `full` | Remote approval ON for the main session — tlive holds each tool call so you can Allow/Deny it from IM / desktop / dashboard, in parallel with the terminal dialog (first answer wins). Sub-agent prompts still pass through to the terminal untouched. | +| `all` | Sub-agent approvals are held too. **A held sub-agent has NO terminal dialog until the window ends** — Claude Code awaits the hook before deciding whether to build one — so this is the "nobody is at the keyboard" posture; `tlive mode full` goes back. | Remote approval is opt-in by design: a freshly-installed tool must never be able to silently hang a workflow. `tlive status` shows the effective mode. +Also settable from IM: `/mode off|notify|full|all` sets it directly, and a +bare `/mode` replies with a card listing every rung and marking the current +one, with tap-to-switch buttons. + +### Sub-agent card buttons + +Two affordances exist only as buttons on IM cards — there is no other way to +reach them: + +- **"Hold sub-agents from now on"** — rides the pass-through notice sent when + a sub-agent prompt falls through to the terminal on `full` (that prompt + itself can only be answered at the keyboard; this button is for the *next* + one). Tapping it switches posture to `all`. +- **"Answer at the terminal instead"** — rides a HELD sub-agent approval card + on `all`. Releases that one request so Claude Code builds its terminal + dialog, exactly as if tlive weren't holding it. +- **"Stop holding sub-agents"** — also on the held sub-agent card. Switches + posture back to `full`. ### `tlive mute|trust|safe on|off` · `tlive desktop on|off` @@ -152,6 +172,7 @@ subcommands. | `/mute on\|off` | Global mute toggle — `off` suppresses all outbound IM notifications. | | `/trust on\|off` | Pause approvals (auto-allow everything) until turned off. High-risk; prefer the per-tool "Always allow" button. | | `/safe on\|off` | Auto-allow routine ops (non-dangerous Bash, non-sensitive edits); the danger floor still asks. | +| `/mode off\|notify\|full\|all` | Set posture. A bare `/mode` replies with a card listing the ladder and marking the current rung, with tap-to-switch buttons. | | `/help` | Show in-chat help. | | *quote-reply + text* | Typed into that session's terminal (wrapped sessions). | | *photo / file* | Downloaded to `~/.tlive/inbox`; path injected into the session. | @@ -159,8 +180,8 @@ subcommands. Tapping a bare command from the client's command menu (which sends `/mute`, `/trust`, or `/safe` with no `on|off` argument) replies with explicit on/off buttons instead of an error — a menu tap can never one-shot enable a dangerous -state like `/trust`. These are approval / posture controls; the mode posture -(`off`/`notify`/`full`) is CLI-only via `tlive mode`. +state like `/trust`. A bare `/mode` works the same way, but replies with the +full ladder (see above) instead of a plain on/off pair. --- diff --git a/plugins/.content-lock.json b/plugins/.content-lock.json index 2272b112..1fd34d85 100644 --- a/plugins/.content-lock.json +++ b/plugins/.content-lock.json @@ -1,4 +1,4 @@ { - "version": "2.5.3", - "hash": "23e236efb5e7e20a01071a2c5016a28122d4e03e4954814d457a276c9af24526" + "version": "2.5.4", + "hash": "6b183a8827ca4c53744ffebd389e38af56ca5427292476d1ae9621075bdf2cf7" } diff --git a/plugins/claude/plugins/tlive/.claude-plugin/plugin.json b/plugins/claude/plugins/tlive/.claude-plugin/plugin.json index ade8ba43..f7bdd7d0 100644 --- a/plugins/claude/plugins/tlive/.claude-plugin/plugin.json +++ b/plugins/claude/plugins/tlive/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "tlive", - "version": "2.5.3", + "version": "2.5.4", "description": "tlive hooks + skill + commands \u2014 IM approvals, session monitoring, web terminal" } diff --git a/plugins/claude/plugins/tlive/commands/setup.md b/plugins/claude/plugins/tlive/commands/setup.md index 3691364f..690ce273 100644 --- a/plugins/claude/plugins/tlive/commands/setup.md +++ b/plugins/claude/plugins/tlive/commands/setup.md @@ -51,7 +51,11 @@ Guide the user through tlive setup. Execute in order, showing each result: whether the user wants to Allow/Deny tool calls from their phone; if yes, run `tlive mode full` (holds each tool call for a remote answer, in parallel with the local prompt — first answer wins; revert any time with `tlive mode - notify`). If they only want monitoring, leave it in `notify`. + notify`). If they only want monitoring, leave it in `notify`. If they also + want sub-agent approvals on their phone (e.g. they're about to step away), + that's `tlive mode all` — flag the trade: a held sub-agent has no terminal + dialog until the window ends, so it only pays off when nobody is at the + keyboard (`tlive mode full` goes back). 9. If status shows the Codex companion as `off` or `degraded`, explain what it means (codex missing from PATH / app-server child failing — see `~/.tlive/codex-appserver.log`); Codex approvals stay local-only until it is diff --git a/plugins/claude/plugins/tlive/commands/status.md b/plugins/claude/plugins/tlive/commands/status.md index 468ed080..5b1f7d8b 100644 --- a/plugins/claude/plugins/tlive/commands/status.md +++ b/plugins/claude/plugins/tlive/commands/status.md @@ -5,8 +5,11 @@ description: Show tlive daemon, channel, and Codex companion status Run `tlive status` and show the output to the user verbatim. If the `mode:` line says `notify` (the default), point out that remote approval is off — tlive only watches + notifies, and tool prompts stay local; enable phone Allow/Deny -with `tlive mode full`. `full` means remote approval is on; `off` disables -tlive entirely. If the Codex line says `degraded` or `off`, explain: Codex +with `tlive mode full`. `full` means remote approval is on for the main +session; `all` means sub-agent approvals are held too (no terminal dialog for +a held one until the window ends — only worth it when nobody is at the +keyboard); `off` disables tlive entirely. If the Codex line says `degraded` or +`off`, explain: Codex approvals are local-only right now (the native prompt still works; nothing is ever auto-run) — `off` usually means codex is not on PATH, `degraded` means the app-server child keeps failing (see `~/.tlive/codex-appserver.log`). diff --git a/plugins/claude/plugins/tlive/skills/tlive/SKILL.md b/plugins/claude/plugins/tlive/skills/tlive/SKILL.md index 650f55a3..5f3b8057 100644 --- a/plugins/claude/plugins/tlive/skills/tlive/SKILL.md +++ b/plugins/claude/plugins/tlive/skills/tlive/SKILL.md @@ -14,11 +14,15 @@ through global hooks; Codex sessions are watched through an app-server companion process (no hooks, no trust step). Completions and failures land in IM (Telegram/Feishu) and the web dashboard, where you can reply-to-continue. The **posture** (`tlive mode`, default `notify`) decides whether approvals are -held for a remote answer: in `notify` tlive only watches + notifies (the shim -never holds an approval — prompts stay 100% native); `tlive mode full` turns on -remote approval (Allow/Deny from IM/desktop/dashboard); `off` makes every hook a -no-op. The daemon auto-starts with new sessions (disable via -`daemon.autoStart: false`). +held for a remote answer, in escalation order: `off` makes every hook a no-op; +`notify` only watches + notifies (the shim never holds an approval — prompts +stay 100% native); `full` turns on remote approval for the main session +(Allow/Deny from IM/desktop/dashboard), in parallel with the terminal dialog — +first answer wins; `all` holds sub-agent approvals too, with **no terminal +dialog** for a held one until the window ends, so use it only when nobody is +at the keyboard (`tlive mode full` goes back). Also settable from IM: `/mode` +(a bare `/mode` replies with the ladder). The daemon auto-starts with new +sessions (disable via `daemon.autoStart: false`). ## Commands - `tlive setup` — configure IM credentials + register the Claude/Codex plugins @@ -27,9 +31,9 @@ no-op. The daemon auto-starts with new sessions (disable via - `tlive status` — daemon health, effective `mode`, channels, and the Codex companion state (`running` / `degraded` / `off`; degraded or off = Codex approvals local-only). -- `tlive mode off|notify|full` — set posture (see intro). Persisted to config, - takes effect on the next hook; `notify` is the default, `full` = remote - approval on. +- `tlive mode off|notify|full|all` — set posture (see intro). Persisted to + config, takes effect on the next hook; `notify` is the default, `full` = + remote approval on, `all` = also holds sub-agent approvals. - `tlive run ` — wrap a process: local terminal + live web terminal (QR to open). - `tlive url` — print the dashboard link + QR code. - `tlive logs -f` — follow the daemon log. @@ -44,12 +48,12 @@ no-op. The daemon auto-starts with new sessions (disable via app-server child keeps dying — see `~/.tlive/codex-appserver.log`. Either way Codex still prompts locally; nothing is ever auto-run. 3. No approval card ever arrives: check `tlive status` — the `mode:` line must - say `full`. The default `notify` never sends approval cards (tool prompts - stay local); enable remote approval with `tlive mode full`. -4. Claude approval card unanswered (in `full`): the local dialog stays live the - whole time (parallel channels, first answer wins); answering locally resolves - the remote card as "answered in terminal". The remote window defaults to ~24h - (`approvals.windowSec`, shared by both vendors). + say `full` or `all`. The default `notify` never sends approval cards (tool + prompts stay local); enable remote approval with `tlive mode full`. +4. Claude approval card unanswered (in `full` or `all`): the local dialog stays + live the whole time (parallel channels, first answer wins); answering + locally resolves the remote card as "answered in terminal". The remote + window defaults to ~24h (`approvals.windowSec`, shared by both vendors). 5. Web page unreachable: `tlive url` for the current link (token is in the URL); phones need the same LAN (or your own reverse proxy/VPN — tlive has no `publicUrl` config, and cards never carry the link). @@ -82,6 +86,10 @@ through: 4. Offer remote approval: tlive defaults to `notify` (watch + notify only). If the user wants to Allow/Deny tool calls from their phone, run `tlive mode full` (holds each tool call for a remote answer; reversible with `tlive mode notify`). - Leave it in `notify` if they only want monitoring. + Leave it in `notify` if they only want monitoring. If they say they're + stepping away and want sub-agent approvals on their phone too, that's + `tlive mode all` — flag the trade plainly: a held sub-agent has no terminal + dialog until the window ends, so it only pays off when nobody is at the + keyboard (`tlive mode full` to come back). 5. Codex needs no extra step — the companion starts with the daemon. If status says `off`/`degraded`, that's diagnostic info, not a setup task. diff --git a/plugins/codex/plugins/tlive/.codex-plugin/plugin.json b/plugins/codex/plugins/tlive/.codex-plugin/plugin.json index a58aac3d..30ef286a 100644 --- a/plugins/codex/plugins/tlive/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/tlive/.codex-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "tlive", - "version": "2.5.3", + "version": "2.5.4", "description": "tlive skill \u2014 IM approvals and session monitoring for Codex via the app-server companion" } diff --git a/plugins/codex/plugins/tlive/skills/tlive/SKILL.md b/plugins/codex/plugins/tlive/skills/tlive/SKILL.md index 650f55a3..5f3b8057 100644 --- a/plugins/codex/plugins/tlive/skills/tlive/SKILL.md +++ b/plugins/codex/plugins/tlive/skills/tlive/SKILL.md @@ -14,11 +14,15 @@ through global hooks; Codex sessions are watched through an app-server companion process (no hooks, no trust step). Completions and failures land in IM (Telegram/Feishu) and the web dashboard, where you can reply-to-continue. The **posture** (`tlive mode`, default `notify`) decides whether approvals are -held for a remote answer: in `notify` tlive only watches + notifies (the shim -never holds an approval — prompts stay 100% native); `tlive mode full` turns on -remote approval (Allow/Deny from IM/desktop/dashboard); `off` makes every hook a -no-op. The daemon auto-starts with new sessions (disable via -`daemon.autoStart: false`). +held for a remote answer, in escalation order: `off` makes every hook a no-op; +`notify` only watches + notifies (the shim never holds an approval — prompts +stay 100% native); `full` turns on remote approval for the main session +(Allow/Deny from IM/desktop/dashboard), in parallel with the terminal dialog — +first answer wins; `all` holds sub-agent approvals too, with **no terminal +dialog** for a held one until the window ends, so use it only when nobody is +at the keyboard (`tlive mode full` goes back). Also settable from IM: `/mode` +(a bare `/mode` replies with the ladder). The daemon auto-starts with new +sessions (disable via `daemon.autoStart: false`). ## Commands - `tlive setup` — configure IM credentials + register the Claude/Codex plugins @@ -27,9 +31,9 @@ no-op. The daemon auto-starts with new sessions (disable via - `tlive status` — daemon health, effective `mode`, channels, and the Codex companion state (`running` / `degraded` / `off`; degraded or off = Codex approvals local-only). -- `tlive mode off|notify|full` — set posture (see intro). Persisted to config, - takes effect on the next hook; `notify` is the default, `full` = remote - approval on. +- `tlive mode off|notify|full|all` — set posture (see intro). Persisted to + config, takes effect on the next hook; `notify` is the default, `full` = + remote approval on, `all` = also holds sub-agent approvals. - `tlive run ` — wrap a process: local terminal + live web terminal (QR to open). - `tlive url` — print the dashboard link + QR code. - `tlive logs -f` — follow the daemon log. @@ -44,12 +48,12 @@ no-op. The daemon auto-starts with new sessions (disable via app-server child keeps dying — see `~/.tlive/codex-appserver.log`. Either way Codex still prompts locally; nothing is ever auto-run. 3. No approval card ever arrives: check `tlive status` — the `mode:` line must - say `full`. The default `notify` never sends approval cards (tool prompts - stay local); enable remote approval with `tlive mode full`. -4. Claude approval card unanswered (in `full`): the local dialog stays live the - whole time (parallel channels, first answer wins); answering locally resolves - the remote card as "answered in terminal". The remote window defaults to ~24h - (`approvals.windowSec`, shared by both vendors). + say `full` or `all`. The default `notify` never sends approval cards (tool + prompts stay local); enable remote approval with `tlive mode full`. +4. Claude approval card unanswered (in `full` or `all`): the local dialog stays + live the whole time (parallel channels, first answer wins); answering + locally resolves the remote card as "answered in terminal". The remote + window defaults to ~24h (`approvals.windowSec`, shared by both vendors). 5. Web page unreachable: `tlive url` for the current link (token is in the URL); phones need the same LAN (or your own reverse proxy/VPN — tlive has no `publicUrl` config, and cards never carry the link). @@ -82,6 +86,10 @@ through: 4. Offer remote approval: tlive defaults to `notify` (watch + notify only). If the user wants to Allow/Deny tool calls from their phone, run `tlive mode full` (holds each tool call for a remote answer; reversible with `tlive mode notify`). - Leave it in `notify` if they only want monitoring. + Leave it in `notify` if they only want monitoring. If they say they're + stepping away and want sub-agent approvals on their phone too, that's + `tlive mode all` — flag the trade plainly: a held sub-agent has no terminal + dialog until the window ends, so it only pays off when nobody is at the + keyboard (`tlive mode full` to come back). 5. Codex needs no extra step — the companion starts with the daemon. If status says `off`/`degraded`, that's diagnostic info, not a setup task. diff --git a/src/adapters/im/telegram.ts b/src/adapters/im/telegram.ts index 7b333b31..4208b540 100644 --- a/src/adapters/im/telegram.ts +++ b/src/adapters/im/telegram.ts @@ -156,6 +156,7 @@ export class TelegramAdapter implements IMAdapter { { command: 'mute', description: 'on|off — mute IM notifications (on = quiet); desktop is separate' }, { command: 'trust', description: 'on|off — pause approvals (auto-allow all) / resume' }, { command: 'safe', description: 'on|off — auto-allow routine ops, still ask for dangerous' }, + { command: 'mode', description: 'off|notify|full|all — how much tlive intercepts' }, { command: 'help', description: 'help and command list' }, ]).catch(() => undefined); // Use grammy's polling but tie to our abort controller via custom client. diff --git a/src/cli/main.ts b/src/cli/main.ts index 47fb8b8b..dd5093a8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -17,8 +17,11 @@ Usage: tlive [args] | tlive --version run [args] wrap a process: local terminal + web terminal url print the web dashboard URL + a QR code (for scanning) hook [--codex] hook shim (invoked by Claude/Codex hooks; --codex = Codex decision wire) - mode off|notify|full posture: notify (default) = watch + notify, never gate approvals; - full = remote approval on; off = do nothing + mode off|notify|full|all posture: off = do nothing; notify (default) = watch + + notify only, never gate approvals; full = remote + approval for the main session; all = also holds + sub-agent approvals (no terminal dialog until answered — + see README) mute on|off mute / unmute IM notifications — on = quiet (same as IM /mute) trust on|off pause approvals (auto-allow ALL) / resume (IM /trust) safe on|off auto-allow routine ops, still ask for dangerous (IM /safe) From 77bac6e606b605b813980f6c32105cf65e4e9466 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 15:51:56 +0800 Subject: [PATCH 13/26] test(telegram): include /mode in the expected bot command menu 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. --- src/adapters/im/__tests__/telegram.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/im/__tests__/telegram.test.ts b/src/adapters/im/__tests__/telegram.test.ts index 7dd34361..c76eb429 100644 --- a/src/adapters/im/__tests__/telegram.test.ts +++ b/src/adapters/im/__tests__/telegram.test.ts @@ -22,7 +22,7 @@ describe('TelegramAdapter', () => { await a.start(); expect(setMyCommands).toHaveBeenCalledOnce(); const cmds = (setMyCommands.mock.calls[0] as any[])[0] as Array<{ command: string }>; - expect(cmds.map((c) => c.command)).toEqual(['mute', 'trust', 'safe', 'help']); + expect(cmds.map((c) => c.command)).toEqual(['mute', 'trust', 'safe', 'mode', 'help']); await a.stop(); }); From 62e0c8d4517990e2f6ab1d5b9731a8d419230883 Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 16:21:39 +0800 Subject: [PATCH 14/26] fix(mode): re-read the posture live for permission_prompt gating 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. --- src/cli/main.ts | 4 ++-- src/kernel/daemon/bootstrap.ts | 32 +++++++++++++++------------- src/kernel/daemon/inbound-handler.ts | 18 ++++++++++------ src/kernel/hook/normalizer.ts | 10 +++++---- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index dd5093a8..db10ce5c 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -20,8 +20,8 @@ Usage: tlive [args] | tlive --version mode off|notify|full|all posture: off = do nothing; notify (default) = watch + notify only, never gate approvals; full = remote approval for the main session; all = also holds - sub-agent approvals (no terminal dialog until answered — - see README) + sub-agent approvals (no terminal dialog until the window + ends — see README) mute on|off mute / unmute IM notifications — on = quiet (same as IM /mute) trust on|off pause approvals (auto-allow ALL) / resume (IM /trust) safe on|off auto-allow routine ops, still ask for dangerous (IM /safe) diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 91b518f6..fba0b56f 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -234,9 +234,6 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise Date: Wed, 29 Jul 2026 16:21:52 +0800 Subject: [PATCH 15/26] test(mode): lock the live-posture fix, handback's wire mapping, and /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:` 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. --- src/kernel/daemon/__tests__/bootstrap.test.ts | 94 +++++++++++++++++++ .../daemon/__tests__/inbound-handler.test.ts | 22 +++++ src/kernel/hook/__tests__/normalizer.test.ts | 3 + tests/contract/cli-surface.test.ts | 2 +- 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/kernel/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index 0cabaa4e..61984418 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -7,6 +7,7 @@ import { request, daemonSocketPath } from '../../ipc/client'; import type { IMAdapter, IMChannel, OutgoingMessage, IncomingEnvelope } from '../../contracts/im-adapter'; import { SessionRegistry } from '../../web/session-registry'; import { until } from '../../__tests__/wait.js'; +import { writeMode } from '../../config/mode.js'; // #45 — robustness helpers for this file's "held request" pattern // (const pending = request(...); …asserts…; await pending). On a slow/jittery @@ -1193,6 +1194,54 @@ describe('permission_prompt forwarding — the notify-mode / immediate-defer not expect(sent).toHaveLength(0); }); + // Mirrors the 'full mode' test above for the top rung: 'all' is ALSO a + // holding posture (every request tlive saw was held or handed back), so it + // must behave identically. Before the fix, `redundant` was computed from + // `mode === 'full'` alone, so 'all' fell through as `redundant = false` and + // built the chain anyway in a posture where it must not run (see the + // comment above `redundant` in bootstrap.ts). + it('all mode: a permission_prompt with nothing held ALSO creates no card, toast or IM text', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ ...CFG, mode: 'all' })); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => {}, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + await request({ kind: 'hook.notify', cwd: '/w', sessionId: 's1', level: 'info', message: MSG, permissionPrompt: true }, { socketPath: sock, timeoutMs: 2000 }); + + await new Promise((r) => setTimeout(r, 80)); + expect(notes).toEqual([]); + expect((await findSession(sock, 's1'))?.pending ?? null).toBeNull(); + expect(sent).toHaveLength(0); + }); + + // The regression this describe block exists to guard against: the posture is + // remotely settable (`tlive mode`, IM's `/mode`) and must never be cached at + // boot. Boot in `full`, then flip to `notify` — exactly "tap `notify` on the + // ladder card while a boot-time-`full` daemon is still running" — and this + // chain must run per the CURRENT posture, not the boot one. The old + // `const mode = effectiveMode(cfg.mode)` (read once at boot) would have left + // `redundant` stuck `true` here, silently re-opening issue #49. + it('a posture change made AFTER boot changes whether this chain runs — not the boot-time posture', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ ...CFG, mode: 'full' })); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => {}, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + writeMode(tmp, 'notify'); + + await request({ kind: 'hook.notify', cwd: '/w', sessionId: 's1', level: 'info', message: MSG, permissionPrompt: true }, { socketPath: sock, timeoutMs: 2000 }); + + // Now on 'notify' — the chain must run: it is the only signal a dialog is waiting. + expect(notes).toHaveLength(1); + expect((await findSession(sock, 's1'))?.pending?.local).toBe(true); + await waitForSent(sent); + expect((sent[0] as { text: string }).text).toContain('answer in the terminal'); + }); + it('notify mode still sends the IM text — there it is the only signal a dialog is waiting', async () => { writeFileSync(join(tmp, 'config.json'), JSON.stringify({ ...CFG, mode: 'notify' })); const sent: OutgoingMessage[] = []; @@ -1285,6 +1334,51 @@ describe('permission_prompt forwarding — the notify-mode / immediate-defer not }); }); +// bootstrap.ts's IPC layer maps BOTH 'local' and 'handback' to the wire +// decision 'defer' (permission-router.ts keeps them as distinct Decision +// values only so the settled card can tell them apart — see OUTCOME). Today +// only a type error would catch that mapping line being removed, and at +// runtime the shim would silently receive a `decision` it does not +// understand. Assert the wire AND the settlement label directly. +describe('handback (Answer at the terminal instead)', () => { + it('maps to defer on the wire, and settles the card as "Handed back to the terminal" — not "Timed out"', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ + mode: 'all', + web: { enabled: false }, + approvals: { approvalGraceSec: 0 }, + adapters: { telegram: { token: 't', chatIdAllowList: ['c1'] } }, + })); + const sent: OutgoingMessage[] = []; + const edits: Array<{ messageId: string; msg: OutgoingMessage }> = []; + const adapter = interactiveAdapter('telegram', sent, edits); + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter] }); + const sock = daemonSocketPath(tmp); + + const pending = held(request( + { kind: 'hook.permission.request', cwd: '/proj', sessionId: 's1', agentId: 'agentA', toolName: 'Bash', input: { command: 'date' }, timeoutSec: 60 }, + { socketPath: sock, timeoutMs: 10_000 }, + )); + await waitForSent(sent); + + const card = sent[0] as { kind: 'card'; buttons?: Array<{ id: string; label: string }> }; + const handbackBtn = card.buttons!.find((b) => b.id.startsWith('handback:'))!; + adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: handbackBtn.id, ts: 0 }); + + // The wire: CC must see a plain pass-through, exactly like a timeout/local + // answer — never a decision it doesn't recognize, never an auto-allow. + const r = await pending; + expect(r).toEqual({ kind: 'hook.permission.result', decision: 'defer' }); + + // The card: the distinct label is the entire justification for 'handback' + // existing as its own Decision instead of just reusing 'defer' (whose + // settlement label is "Timed out" — a lie here, nobody waited it out). + expect(edits).toHaveLength(1); + const edited = edits[0].msg as { title?: string }; + expect(edited.title).toContain('Handed back to the terminal'); + expect(edited.title).not.toContain('Timed out'); + }); +}); + describe('sub-agent card affordances', () => { it('on mode all, a held sub-agent card offers a way back to the terminal and a posture switch', async () => { writeFileSync(join(tmp, 'config.json'), JSON.stringify({ diff --git a/src/kernel/daemon/__tests__/inbound-handler.test.ts b/src/kernel/daemon/__tests__/inbound-handler.test.ts index 5e2331f7..89acfc5c 100644 --- a/src/kernel/daemon/__tests__/inbound-handler.test.ts +++ b/src/kernel/daemon/__tests__/inbound-handler.test.ts @@ -124,6 +124,7 @@ describe('InboundHandler', () => { expect(card.body).toContain('`/mute on|off`'); expect(card.body).toContain('`/trust on|off`'); expect(card.body).toContain('`/safe on|off`'); + expect(card.body).toContain('/mode'); expect(card.body).not.toContain('/desktop'); // machine-local, dropped from IM }); @@ -765,6 +766,27 @@ describe('/mode from IM', () => { expect(setMode).toHaveBeenCalledWith('full'); }); + it('all → full via the sub-agent card button warns that already-held sub-agent requests stay held', async () => { + // The button lives on a HELD sub-agent card (bootstrap.ts: `mode:full`, + // 'Stop holding sub-agents'). It only changes the posture for requests from + // here on — the one in hand stays held with no terminal dialog, which is + // why the card exists at all. Silence here reads as "and give me this one + // back too", which is false. + const setMode = vi.fn(); + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'all', setMode })); + await h.handle(envelope({ text: 'mode:full' })); + expect(msgs[0].text).toContain('stay held'); + expect(msgs[0].text).toContain('Answer at the terminal instead'); + }); + + it('a same-rung tap and every OTHER transition carry no stale-hold notice', async () => { + const msgs: Array<{ kind: string; text?: string }> = []; + const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter(msgs), getMode: () => 'notify' })); + await h.handle(envelope({ text: 'mode:full' })); // notify → full, not all → full + expect(msgs[0].text).not.toContain('stay held'); + }); + it('an unknown level in a callback is ignored, not applied', async () => { const setMode = vi.fn(); const h = new InboundHandler(baseDeps({ imBy: () => makeAdapter([]), setMode })); diff --git a/src/kernel/hook/__tests__/normalizer.test.ts b/src/kernel/hook/__tests__/normalizer.test.ts index f5f87cdd..eba4e885 100644 --- a/src/kernel/hook/__tests__/normalizer.test.ts +++ b/src/kernel/hook/__tests__/normalizer.test.ts @@ -178,6 +178,9 @@ describe('sessionStartOut(欢迎提示)', () => { expect(o.hookSpecificOutput.additionalContext).toContain('帮我配置 tlive'); }); it('claude + 已配置 + full → {}', () => expect(sessionStartOut('claude', true, 'full')).toBe('{}')); + it('claude + 已配置 + all → {}(all 也是"远程审批开着",不是该被提醒"关了"的状态)', () => { + expect(sessionStartOut('claude', true, 'all')).toBe('{}'); + }); it('claude + 已配置 + notify → 提示远程审批是关的(引导 tlive mode full)', () => { const o = JSON.parse(sessionStartOut('claude', true, 'notify')); expect(o.hookSpecificOutput.hookEventName).toBe('SessionStart'); diff --git a/tests/contract/cli-surface.test.ts b/tests/contract/cli-surface.test.ts index 4acd3339..9eddb003 100644 --- a/tests/contract/cli-surface.test.ts +++ b/tests/contract/cli-surface.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { CLI_SUBCOMMANDS } from '../../src/kernel/contracts/cli-surface.js'; describe('v2 CLI surface', () => { - it('is exactly the 8 core subcommands + `mode` (posture: off/notify/full, persisted to config) + the 4 runtime toggles (mute/trust/safe/desktop — same setters as the IM commands)', () => { + it('is exactly the 8 core subcommands + `mode` (posture: off/notify/full/all, persisted to config) + the 4 runtime toggles (mute/trust/safe/desktop — same setters as the IM commands)', () => { expect([...CLI_SUBCOMMANDS].sort()).toEqual([ 'desktop', 'hook', 'logs', 'mode', 'mute', 'run', 'safe', 'setup', 'start', 'status', 'stop', 'trust', 'url', ]); From a4333d08b37745a9e92b053531510b7f108f7b9a Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 16:22:05 +0800 Subject: [PATCH 16/26] docs(mode): finish the four-rung sweep and note the removed config key - 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). --- README.md | 6 +++++- README_CN.md | 5 ++++- docs/getting-started-cn.md | 8 ++++++++ docs/getting-started.md | 9 +++++++++ docs/manual-hooks.md | 19 +++++++++++-------- plugins/.content-lock.json | 4 ++-- .../plugins/tlive/.claude-plugin/plugin.json | 2 +- .../claude/plugins/tlive/commands/setup.md | 2 +- .../plugins/tlive/skills/tlive/SKILL.md | 3 ++- .../plugins/tlive/.codex-plugin/plugin.json | 2 +- .../codex/plugins/tlive/skills/tlive/SKILL.md | 3 ++- 11 files changed, 46 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4c1bad3d..b69179bc 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ continue" line makes the distinction moot for what you actually do. session is needed, and `tlive status` shows the effective mode. Remote approval is opt-in by design — a freshly-installed tool must never be able to silently hang a workflow. -- **Approvals** *(remote approval — `mode: full`)* — a tool call that needs +- **Approvals** *(remote approval — `mode: full` or `all`)* — a tool call that needs approval is held so you can answer it from IM buttons or the web card: - **Parallel, first-answer-wins** — on Claude Code the `PermissionRequest` hook fires alongside the local permission dialog; both are live, and @@ -340,6 +340,10 @@ message to type into that session. "autoStart": true // default true; false disables session-start lazy-start }, "approvals": { + // `holdSubagents` was removed — `tlive mode all` replaces it (upgrading + // from a config with `"holdSubagents": true`: that key is now dead and + // silently ignored, so switch to `tlive mode all` to keep holding + // sub-agent approvals). // remote-approval window in seconds, shared by both vendors. The remote // channel runs parallel to the local prompt, so a long window costs // nothing — timing out never approves or denies anything, it only diff --git a/README_CN.md b/README_CN.md index 95887c40..23d2fe13 100644 --- a/README_CN.md +++ b/README_CN.md @@ -84,7 +84,7 @@ IM 消息带 `label · ` 前缀(会话目录名),但不再用图标区分包装/ 发 `/mode`——裸发也会回一张列出梯子并标出当前档的卡);shim 每个 hook 都 重读 config,无需重启也无需新会话,`tlive status` 会显示当前生效的 mode。 远程审批设计成 opt-in——刚装好的工具绝不该能悄悄挂起你的工作流。 -- **审批** *(远程审批 —— `mode: full`)* —— 需要审批的工具调用会被 hold 住, +- **审批** *(远程审批 —— `mode: full` 或 `all`)* —— 需要审批的工具调用会被 hold 住, 让你从 IM 按钮或 web 卡作答: - **并行、先答先得** —— Claude Code 上 `PermissionRequest` hook 与本地权限 对话并行;两边都活,键盘上答掉后远程卡几秒内自动收尾("已在终端处理")。 @@ -284,6 +284,9 @@ IM 命令:`/mute on|off`(静音 IM 通知)、`/trust on|off`(暂停审批—— "autoStart": true // 默认 true;设 false 关闭 session-start 懒启动 }, "approvals": { + // `holdSubagents` 已删除 —— 由 `tlive mode all` 取代(如果你的配置里还有 + // `"holdSubagents": true`:这个键现在是死的,会被静默忽略,想继续 hold + // 子代理审批请改用 `tlive mode all`)。 // 远程审批窗口(秒),两家共用。远程通道与本地提示并行,窗口开长 // 也不费事——超时不批也不拒,只是把你逼回键盘而已。 "windowSec": 86200, // 默认约 24 小时(同时是上限;最小 60) diff --git a/docs/getting-started-cn.md b/docs/getting-started-cn.md index eaecc1ca..e3938651 100644 --- a/docs/getting-started-cn.md +++ b/docs/getting-started-cn.md @@ -117,6 +117,14 @@ tlive run claude 2. 你在手机上点 **Allow** 或 **Deny**(或在键盘前答)。 3. hook 把决定返回给 Claude,Claude 继续或中止。 +**`all`**(`tlive mode all`)——`full` 的一切,外加子代理审批: + +1. 子代理的工具调用也会被 hold 住、变得可远程作答,和主会话审批一样——但有 + 一个代价:Claude Code 要等 hook 返回之后才决定要不要建子代理的本地框, + 所以一个被 hold 的子代理在窗口结束前(超时,或手机/dashboard 作答落地) + **没有**终端提示。只在没人在键盘前时用它;`tlive mode full` 改回让子代理 + 提示直接透传。 + 绝不自动放行、也绝不全拒:守护进程不可达、窗口超时、或没配 chat 时,hook 回落 `{}`,控制权退回本地终端,就像 tlive 不存在。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 7dc62bf5..e34aeb49 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -134,6 +134,15 @@ call: 2. You tap **Allow** or **Deny** on your phone (or answer at the keyboard). 3. The hook returns the decision to Claude; Claude continues or aborts. +**`all`** (`tlive mode all`) — everything in `full`, plus sub-agent approvals: + +1. Sub-agent tool calls are held and made remotely answerable too, exactly + like a main-session approval — with one trade: Claude Code decides whether + to build a sub-agent's local dialog only *after* the hook returns, so a + held sub-agent has **no** terminal prompt until the window ends (a timeout, + or the phone/dashboard answer). Use it when nobody is at the keyboard; + `tlive mode full` goes back to passing sub-agent prompts straight through. + Nothing is ever auto-approved or blanket-denied: if the daemon is unreachable, the window expires, or no chat is configured, the hook falls back to `{}` and control returns to the local terminal as if tlive weren't there. diff --git a/docs/manual-hooks.md b/docs/manual-hooks.md index a7346b8e..75a1d845 100644 --- a/docs/manual-hooks.md +++ b/docs/manual-hooks.md @@ -39,14 +39,17 @@ overwriting). } ``` -Approval gating only happens in `mode: full` (remote approval — opt-in via -`tlive mode full`); tlive's default posture is `notify`, where the shim passes -every `PermissionRequest` through untouched and prompts stay 100% local. When -it is on, approval gating on Claude Code rides `PermissionRequest`, which runs -in PARALLEL with the local permission dialog: both are live, first answer wins, -and a local answer releases the remote card within seconds. The 24-hour -`timeout` is what keeps the remote card answerable while you're away from the -keyboard. +Approval gating only happens in `mode: full` or `mode: all` (remote approval — +opt-in via `tlive mode full`, or `tlive mode all` to also hold sub-agent +approvals); tlive's default posture is `notify`, where the shim passes every +`PermissionRequest` through untouched and prompts stay 100% local. When it is +on, approval gating on Claude Code rides `PermissionRequest`, which runs in +PARALLEL with the local permission dialog for a main-session request: both are +live, first answer wins, and a local answer releases the remote card within +seconds. (A held sub-agent request under `all` is the one exception — Claude +Code decides whether to build its dialog only after the hook returns, so there +is no local dialog to race until the window ends.) The 24-hour `timeout` is +what keeps the remote card answerable while you're away from the keyboard. `tlive hook ` must resolve on `PATH` (the same binary `tlive setup` installs). No further action needed on the Claude Code side — hooks fire as diff --git a/plugins/.content-lock.json b/plugins/.content-lock.json index 1fd34d85..ed3f0f6c 100644 --- a/plugins/.content-lock.json +++ b/plugins/.content-lock.json @@ -1,4 +1,4 @@ { - "version": "2.5.4", - "hash": "6b183a8827ca4c53744ffebd389e38af56ca5427292476d1ae9621075bdf2cf7" + "version": "2.5.5", + "hash": "fb80b63d0cea6b94c51248093326cb03d625706894f1fa48e5ca1fa0d8b21a7a" } diff --git a/plugins/claude/plugins/tlive/.claude-plugin/plugin.json b/plugins/claude/plugins/tlive/.claude-plugin/plugin.json index f7bdd7d0..d02fc404 100644 --- a/plugins/claude/plugins/tlive/.claude-plugin/plugin.json +++ b/plugins/claude/plugins/tlive/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "tlive", - "version": "2.5.4", + "version": "2.5.5", "description": "tlive hooks + skill + commands \u2014 IM approvals, session monitoring, web terminal" } diff --git a/plugins/claude/plugins/tlive/commands/setup.md b/plugins/claude/plugins/tlive/commands/setup.md index 690ce273..5bb1c62a 100644 --- a/plugins/claude/plugins/tlive/commands/setup.md +++ b/plugins/claude/plugins/tlive/commands/setup.md @@ -15,7 +15,7 @@ Guide the user through tlive setup. Execute in order, showing each result: b. Have the user send the bot a message and confirm the reply arrives (proves the inbound path, not just the config). c. Ask the user to check the bot's command menu shows - /mute /trust /safe /help (a stale client cache → close and + /mute /trust /safe /mode /help (a stale client cache → close and reopen the chat). d. Desktop notifications: explain they fire on the computer only for things that need you to act — a pending approval, or the idle "waiting for your diff --git a/plugins/claude/plugins/tlive/skills/tlive/SKILL.md b/plugins/claude/plugins/tlive/skills/tlive/SKILL.md index 5f3b8057..edbff2ba 100644 --- a/plugins/claude/plugins/tlive/skills/tlive/SKILL.md +++ b/plugins/claude/plugins/tlive/skills/tlive/SKILL.md @@ -66,7 +66,8 @@ sessions (disable via `daemon.autoStart: false`). (rm -rf, sudo, .env/.ssh writes…) still asks and no config can lower it. `/trust on` pauses approvals entirely (high risk — pair with allowedSenders). - Runtime switches flip the same state from either entrance: IM commands - (/mute /trust /safe) and the CLI (`tlive mute|trust|safe on|off`). `/mute on` + (/mute /trust /safe on|off, /mode for the posture ladder) and the CLI + (`tlive mute|trust|safe on|off`, `tlive mode off|notify|full|all`). `/mute on` = go quiet; it silences IM notifications ONLY. The desktop toast is a separate, independent surface (IM ⊥ desktop): CLI-only `tlive desktop on|off`, no IM command, unaffected by `/mute`. It fires only for things that need you to act: diff --git a/plugins/codex/plugins/tlive/.codex-plugin/plugin.json b/plugins/codex/plugins/tlive/.codex-plugin/plugin.json index 30ef286a..3093bc0c 100644 --- a/plugins/codex/plugins/tlive/.codex-plugin/plugin.json +++ b/plugins/codex/plugins/tlive/.codex-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "tlive", - "version": "2.5.4", + "version": "2.5.5", "description": "tlive skill \u2014 IM approvals and session monitoring for Codex via the app-server companion" } diff --git a/plugins/codex/plugins/tlive/skills/tlive/SKILL.md b/plugins/codex/plugins/tlive/skills/tlive/SKILL.md index 5f3b8057..edbff2ba 100644 --- a/plugins/codex/plugins/tlive/skills/tlive/SKILL.md +++ b/plugins/codex/plugins/tlive/skills/tlive/SKILL.md @@ -66,7 +66,8 @@ sessions (disable via `daemon.autoStart: false`). (rm -rf, sudo, .env/.ssh writes…) still asks and no config can lower it. `/trust on` pauses approvals entirely (high risk — pair with allowedSenders). - Runtime switches flip the same state from either entrance: IM commands - (/mute /trust /safe) and the CLI (`tlive mute|trust|safe on|off`). `/mute on` + (/mute /trust /safe on|off, /mode for the posture ladder) and the CLI + (`tlive mute|trust|safe on|off`, `tlive mode off|notify|full|all`). `/mute on` = go quiet; it silences IM notifications ONLY. The desktop toast is a separate, independent surface (IM ⊥ desktop): CLI-only `tlive desktop on|off`, no IM command, unaffected by `/mute`. It fires only for things that need you to act: From d7f3cc622a8b15953c3ef1b0918ded50670442af Mon Sep 17 00:00:00 2001 From: y49 Date: Wed, 29 Jul 2026 16:54:11 +0800 Subject: [PATCH 17/26] fix(mode): a passed-through sub-agent dialog now fires the desktop toast and dashboard card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/kernel/daemon/__tests__/bootstrap.test.ts | 166 ++++++++++++++++++ src/kernel/daemon/bootstrap.ts | 82 ++++++--- 2 files changed, 225 insertions(+), 23 deletions(-) diff --git a/src/kernel/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index 61984418..7fa8150a 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -1063,6 +1063,172 @@ describe('sub-agent pass-through tells you what is blocked', () => { await new Promise((r) => setTimeout(r, 120)); expect(edits).toEqual([]); }); + + async function findSession(sock: string, id: string) { + const r = await request({ kind: 'session.list' }, { socketPath: sock, timeoutMs: 2000 }); + if (r.kind !== 'session.list') throw new Error('bad reply'); + return r.sessions.find((s) => s.id === id); + } + + // The IM notice (above) is only half the gap: with no IM configured, or + // `/mute` on, or the user simply not looking at their phone, a blocked + // sub-agent used to produce NO signal at all. The desktop toast is + // precisely the "at this machine, not watching the terminal" channel. + it('fires ONE desktop ping naming the tool when a sub-agent request passes through', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => {}, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + + await until(() => { expect(notes).toHaveLength(1); }); + expect(notes[0].title).toContain('Bash'); + expect(notes[0].title).toContain('sub-agent'); + expect(notes[0].body).toContain('Waiting at the terminal'); + }); + + // IM ⊥ desktop: `/mute` is an IM-only switch. The old early-return at the + // top of onPassthrough killed the WHOLE announcement on mute, which broke + // that rule for this notice specifically. + it('IM mute silences the card, but the desktop ping still fires (IM ⊥ desktop)', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => {}, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + await request({ kind: 'daemon.set', key: 'mute', enabled: true }, { socketPath: sock, timeoutMs: 2000 }); // /mute on + + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + + await until(() => { expect(notes).toHaveLength(1); }); // desktop unaffected by IM mute… + await new Promise((r) => setTimeout(r, 80)); + expect(sent).toHaveLength(0); // …but the IM card never went out + }); + + // Reuses the same read-only shape as the notify-mode local-prompt card + // (`local: true`) — there is no held request behind it, so Allow/Deny would + // be a button that cannot work. The registry holds ONE pending slot per + // session key, so a sub-agent notice must never evict an already-held + // main-session approval. + it('upserts a read-only dashboard card, but never evicts an already-held main-session approval', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter] }); + const sock = daemonSocketPath(tmp); + + // Fresh session, nothing held — the pass-through alone creates the card. + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + const s1 = await findSession(sock, 's1'); + expect(s1?.pending?.local).toBe(true); + expect(s1?.pending?.title).toContain('sub-agent'); + expect(s1?.pending?.requestId).toContain('a-77'); + + // A held MAIN-session approval (no agentId) already owns 's2' — a + // sub-agent notice for the SAME key must not steal the pending slot. + const heldMain = held(request( + { kind: 'hook.permission.request', cwd: '/w2', sessionId: 's2', toolName: 'Bash', input: { command: 'ls' }, timeoutSec: 60 }, + { socketPath: sock, timeoutMs: 10_000 }, + )); + await waitForSent(sent); + const before = await findSession(sock, 's2'); + const heldRequestId = before?.pending?.requestId; + expect(heldRequestId).toBeTruthy(); + expect(before?.pending?.local).toBeUndefined(); // the answerable card + + await request( + { + kind: 'hook.permission.request', cwd: '/w2', sessionId: 's2', agentId: 'a-88', + toolName: 'Read', input: { file_path: '/etc/hosts' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + const after = await findSession(sock, 's2'); + expect(after?.pending?.requestId).toBe(heldRequestId); // untouched — never overwritten + void heldMain; // resolved by the daemon's own shutdown() → settleAllPending + }); + + // Retirement (measured live: the sub-agent's PostToolUse carrying the same + // (agentId, toolName) pair) must close BOTH the dashboard card and the + // desktop toast when nothing else is waiting — and must not close the toast + // early when a held approval elsewhere is still pending, since the toast is + // one shared machine-wide slot. + it('a matching PostToolUse clears the dashboard card and closes the toast — but not while a held approval is still pending', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + const clears: number[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => { clears.push(1); }, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + // Nothing else pending: retirement closes both surfaces. + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await until(() => { expect(notes).toHaveLength(1); }); + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', agentId: 'a-77', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await until(() => { expect(clears.length).toBeGreaterThan(0); }); + expect((await findSession(sock, 's1'))?.pending).toBeUndefined(); + + // A held main-session approval on a DIFFERENT session is still + // outstanding when THIS session's sub-agent notice retires. + const heldMain = held(request( + { kind: 'hook.permission.request', cwd: '/w2', sessionId: 's2', toolName: 'Bash', input: { command: 'ls' }, timeoutSec: 60 }, + { socketPath: sock, timeoutMs: 10_000 }, + )); + await until(() => { expect(notes.length).toBeGreaterThanOrEqual(2); }); + clears.length = 0; + + await request( + { + kind: 'hook.permission.request', cwd: '/w3', sessionId: 's3', agentId: 'a-99', + toolName: 'Read', input: { file_path: '/etc/hosts' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await until(() => { expect(notes.length).toBeGreaterThanOrEqual(3); }); + expect((await findSession(sock, 's3'))?.pending?.local).toBe(true); + + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w3', sessionId: 's3', agentId: 'a-99', toolName: 'Read', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await new Promise((r) => setTimeout(r, 100)); + expect(clears).toEqual([]); // s2's held approval still pending → toast stays open + expect((await findSession(sock, 's3'))?.pending).toBeUndefined(); // s3's own card still retires + void heldMain; // resolved by the daemon's own shutdown() → settleAllPending + }); }); // Telegram caps callback_data at 64 BYTES and rejects the ENTIRE sendMessage diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index fba0b56f..1fab67ec 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -445,10 +445,29 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise>(); const passthruKey = (key: string, agentId: string, toolName: string): string => `${key}\u0000${agentId}\u0000${toolName}`; + /** requestId for the dashboard's read-only pending card on a pass-through. */ + const passthruRequestId = (agentId: string, toolName: string): string => `passthru:${agentId}:${toolName}`; + + /** Sub-agent dialogs handed back to the terminal and not yet observed running. + * Separate from passthruNotices (which only has entries when an IM card was + * actually sent) because the desktop toast must work with no IM at all — this + * is what lets retirePassthruNotice close the toast even when there was never + * a card to edit. */ + const passthruWaiting = new Set(); // passthruKey(key, agentId, toolName) + /** The sub-agent's tool ran, so its dialog was answered at the keyboard. Mark - * the notice so it stops reading as "still waiting". */ - const retirePassthruNotice = (key: string, agentId: string, toolName: string): void => { + * the notice so it stops reading as "still waiting", clear its read-only + * dashboard card, and close the desktop toast once nothing else is waiting. */ + const retirePassthruNotice = (key: string, cwd: string, agentId: string, toolName: string): void => { const id = passthruKey(key, agentId, toolName); + passthruWaiting.delete(id); + // Same guard as onResolved: only clear the registry's ONE pending slot if + // THIS notice still owns it — a main-session held approval (or a + // different pass-through that raced in) must survive untouched. + if (sessions.get(key)?.pending?.requestId === passthruRequestId(agentId, toolName)) { + events.broadcast({ type: 'session-upsert', session: sessions.upsert({ key, cwd, pending: null }) }); + } + if (permissionRouter.pendingCount() === 0 && localPrompts.count() === 0 && passthruWaiting.size === 0) void desktop.clear(); const notices = passthruNotices.get(id); if (!notices) return; passthruNotices.delete(id); @@ -515,27 +534,44 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { logJson('permission.passthrough.notice', { key, agentId, toolName }); - if (muted || sessions.get(key)?.muted) return; - const notice = `${body}\n\n_Waiting at the terminal — a sub-agent's prompt can only be answered there._`; - for (const t of configuredChats()) { - void sendToChat(t, { - title: `${toolName} · sub-agent`, body: notice, cwd: key, - // The one gap this notice cannot close: THIS dialog can only be answered - // at the keyboard. What it can do is stop the next one from being lost — - // one tap moves the posture up so the rest of this run comes to you. - buttons: [{ id: 'mode:all', label: 'Hold sub-agents from now on' }], - onSent: (s) => { - const id = passthruKey(key, agentId, toolName); - const list = passthruNotices.get(id) ?? []; - list.push({ channel: s.channel, messageId: s.messageId, body: notice }); - passthruNotices.set(id, list); - }, - }) - .catch((e: unknown) => { - logJson('permission.passthrough.undelivered', { key, agentId, toolName, channel: t.channel, error: e instanceof Error ? e.message : String(e) }); - }); + void title; // the IM/dashboard titles below are the sub-agent-specific " · sub-agent", not the generic card title + passthruWaiting.add(passthruKey(key, agentId, toolName)); + // Desktop toast: the "at this machine, not watching the terminal" signal. + // Gated ONLY by desktopOn — never by IM mute (IM ⊥ desktop), so it fires + // with no IM configured at all, exactly like onPending's ping below. + if (desktopOn) void desktop.ping(`${sessionTag(key)}${toolName} · sub-agent`, 'Waiting at the terminal — answer it there.'); + // Dashboard: read-only pending (local: true) — there is no held request + // behind this, so Allow/Deny would be a button that cannot work (same + // rule as the notify-mode local-prompt card). ONE pending slot per + // session key: never evict an already-held main-session approval. + if (!sessions.get(key)?.pending) { + events.broadcast({ type: 'session-upsert', session: sessions.upsert({ key, cwd, status: 'waiting-approval', pending: { + requestId: passthruRequestId(agentId, toolName), title: `${toolName} · sub-agent`, body, local: true, + } }) }); + } + // IM card stays mute-gated (/mute is IM-only) — the desktop toast and + // dashboard card above must fire regardless of it. + if (!(muted || sessions.get(key)?.muted)) { + const notice = `${body}\n\n_Waiting at the terminal — a sub-agent's prompt can only be answered there._`; + for (const t of configuredChats()) { + void sendToChat(t, { + title: `${toolName} · sub-agent`, body: notice, cwd: key, + // The one gap this notice cannot close: THIS dialog can only be answered + // at the keyboard. What it can do is stop the next one from being lost — + // one tap moves the posture up so the rest of this run comes to you. + buttons: [{ id: 'mode:all', label: 'Hold sub-agents from now on' }], + onSent: (s) => { + const id = passthruKey(key, agentId, toolName); + const list = passthruNotices.get(id) ?? []; + list.push({ channel: s.channel, messageId: s.messageId, body: notice }); + passthruNotices.set(id, list); + }, + }) + .catch((e: unknown) => { + logJson('permission.passthrough.undelivered', { key, agentId, toolName, channel: t.channel, error: e instanceof Error ? e.message : String(e) }); + }); + } } - void cwd; // the registry key is what routes replies; cwd is display-only here }, onPending: ({ key, cwd, requestId, title, body, toolName, ask }) => { // Desktop ping FIRST — this notification is for the person AT this @@ -1024,7 +1060,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise Date: Wed, 29 Jul 2026 17:08:16 +0800 Subject: [PATCH 18/26] fix(mode): extend the desktop-clear condition to every site, not just the new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/kernel/daemon/__tests__/bootstrap.test.ts | 102 ++++++++++++++++++ src/kernel/daemon/bootstrap.ts | 24 ++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/kernel/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index 7fa8150a..2f1a56bd 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -1229,6 +1229,108 @@ describe('sub-agent pass-through tells you what is blocked', () => { expect((await findSession(sock, 's3'))?.pending).toBeUndefined(); // s3's own card still retires void heldMain; // resolved by the daemon's own shutdown() → settleAllPending }); + + // Regression: the desktop-clear condition must be ONE predicate evaluated + // identically everywhere — not three separate copies. A copy that forgets + // passthruWaiting closes a still-outstanding sub-agent toast the INSTANT + // something else the daemon tracks resolves, anywhere. This drives that + // through onResolved specifically: s1's sub-agent dialog is untouched + // (nothing retires it in this test) while a genuinely separate, genuinely + // held main-session approval on s2 gets answered via IM. + it("a held approval resolving on ANOTHER session must not close the toast while a sub-agent pass-through is still outstanding (onResolved)", async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify(CFG)); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + const clears: number[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => { clears.push(1); }, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + // s1: sub-agent pass-through — still outstanding for the rest of this test. + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await until(() => { expect(notes).toHaveLength(1); }); + + // s2: a genuine held MAIN-session approval, answered via IM → onResolved fires. + const pending = request( + { kind: 'hook.permission.request', cwd: '/w2', sessionId: 's2', toolName: 'Bash', input: { command: 'ls' }, timeoutSec: 60 }, + { socketPath: sock, timeoutMs: 10_000 }, + ); + held(pending); + // sent[] also carries s1's pass-through IM notice (its own "mode:all" + // button) — find the actual approvable card, not just sent[0]. + await vi.waitFor(() => { + expect((sent as Array<{ buttons?: Array<{ id: string }> }>).some((m) => m.buttons?.some((b) => b.id.startsWith('approve:')))).toBe(true); + }, { timeout: 3000, interval: 10 }); + const card = (sent as Array<{ buttons?: Array<{ id: string; label: string }> }>).find((m) => m.buttons?.some((b) => b.id.startsWith('approve:')))!; + adapter.fire({ channel: 'telegram', chatId: 'c1', userId: 'u1', messageId: 'x1', text: card.buttons!.find((b) => b.id.startsWith('approve:'))!.id, ts: 0 }); + await pending; + + // s2's own approval just resolved — s1's sub-agent dialog is still + // unanswered at the terminal, so the toast must NOT have closed. + expect(clears).toEqual([]); + + // Only once s1's notice actually retires does the toast close. + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', agentId: 'a-77', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await until(() => { expect(clears.length).toBeGreaterThan(0); }); + }); + + // Mirrors the test above for the OTHER pre-existing copy of the condition: + // clearLocalPrompt (the notify-mode / full-mode-defer CC-native dialog + // tracker) must not close the toast either, while a sub-agent pass-through + // elsewhere is still outstanding. mode: 'notify' is required to get a + // tracked local prompt at all — 'full' treats permission_prompt as + // redundant and never calls localPrompts.note in the first place. + it('clearing a local prompt on ANOTHER session must not close the toast while a sub-agent pass-through is still outstanding (clearLocalPrompt)', async () => { + writeFileSync(join(tmp, 'config.json'), JSON.stringify({ ...CFG, mode: 'notify' })); + const sent: OutgoingMessage[] = []; + const adapter = interactiveAdapter('telegram', sent); + const notes: Array<{ title: string; body: string }> = []; + const clears: number[] = []; + h = await bootstrapDaemon({ home: tmp, imAdapters: [adapter], desktopNotifier: { ping: async (title, body) => { notes.push({ title, body }); }, clear: async () => { clears.push(1); }, info: async () => {} } }); + const sock = daemonSocketPath(tmp); + + // s1: sub-agent pass-through — still outstanding for the rest of this test. + await request( + { + kind: 'hook.permission.request', cwd: '/w', sessionId: 's1', agentId: 'a-77', + toolName: 'Bash', input: { command: 'rm -rf /tmp/scratch' }, timeoutSec: 60, + }, + { socketPath: sock, timeoutMs: 5000 }, + ); + await until(() => { expect(notes).toHaveLength(1); }); + + // s2: a notify-mode CC-native dialog (no held request), tracked… + await request( + { kind: 'hook.notify', cwd: '/w2', sessionId: 's2', level: 'info', message: 'Claude needs your permission to use Bash', permissionPrompt: true }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await until(() => { expect(notes.length).toBeGreaterThanOrEqual(2); }); + + // …then answered at the keyboard → clearLocalPrompt fires. + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w2', sessionId: 's2', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await new Promise((r) => setTimeout(r, 100)); + // s1's sub-agent dialog is still unanswered — the toast must NOT have closed. + expect(clears).toEqual([]); + + // Only once s1's notice actually retires does the toast close. + await request( + { kind: 'hook.event', event: { event: 'activity', cwd: '/w', sessionId: 's1', agentId: 'a-77', toolName: 'Bash', result: {} } }, + { socketPath: sock, timeoutMs: 2000 }, + ); + await until(() => { expect(clears.length).toBeGreaterThan(0); }); + }); }); // Telegram caps callback_data at 64 BYTES and rejects the ENTIRE sendMessage diff --git a/src/kernel/daemon/bootstrap.ts b/src/kernel/daemon/bootstrap.ts index 1fab67ec..20211520 100644 --- a/src/kernel/daemon/bootstrap.ts +++ b/src/kernel/daemon/bootstrap.ts @@ -276,7 +276,7 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise { if (!localPrompts.clear({ key, ...(sessionId ? { sessionId } : {}) })) return; if (sessions.get(key)?.pending?.local) sessions.upsert({ key, cwd, pending: null }); - if (permissionRouter.pendingCount() === 0 && localPrompts.count() === 0) void desktop.clear(); + if (nothingWaiting()) void desktop.clear(); }; // Same lifetime as the ask flow's *pending* window, but never consumed by an @@ -455,6 +455,18 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise(); // passthruKey(key, agentId, toolName) + /** The single "is anything at all waiting for the user" predicate behind the + * desktop toast's lifetime. THREE surfaces feed it today (a held approval / + * a tracked CC-native local prompt / an outstanding sub-agent pass-through) + * and every one of them MUST be represented here — this used to be three + * separate copies of the same condition, and the copy at two of the three + * call sites silently forgot passthruWaiting, so a main-session approval + * resolving (or a local prompt clearing) anywhere closed a still-waiting + * sub-agent's toast. The next surface that gets its own waiting-tracker + * must be added HERE, not at whichever call site happens to need it. */ + const nothingWaiting = (): boolean => + permissionRouter.pendingCount() === 0 && localPrompts.count() === 0 && passthruWaiting.size === 0; + /** The sub-agent's tool ran, so its dialog was answered at the keyboard. Mark * the notice so it stops reading as "still waiting", clear its read-only * dashboard card, and close the desktop toast once nothing else is waiting. */ @@ -463,11 +475,13 @@ export async function bootstrapDaemon(opts: BootstrapOpts): Promise Date: Wed, 29 Jul 2026 17:38:05 +0800 Subject: [PATCH 19/26] fix(daemon): register a session before hook.notify renders its label tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "