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..b69179bc 100644 --- a/README.md +++ b/README.md @@ -77,22 +77,30 @@ 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. -- **Approvals** *(remote approval — `mode: full`)* — a tool call that needs +- **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` 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 @@ -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"] }, @@ -327,6 +340,10 @@ 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 @@ -353,14 +370,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..23d2fe13 100644 --- a/README_CN.md +++ b/README_CN.md @@ -68,18 +68,23 @@ 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 住, +- **审批** *(远程审批 —— `mode: full` 或 `all`)* —— 需要审批的工具调用会被 hold 住, 让你从 IM 按钮或 web 卡作答: - **并行、先答先得** —— Claude Code 上 `PermissionRequest` hook 与本地权限 对话并行;两边都活,键盘上答掉后远程卡几秒内自动收尾("已在终端处理")。 @@ -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"] }, @@ -275,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) @@ -294,12 +306,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/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 2272b112..ed3f0f6c 100644 --- a/plugins/.content-lock.json +++ b/plugins/.content-lock.json @@ -1,4 +1,4 @@ { - "version": "2.5.3", - "hash": "23e236efb5e7e20a01071a2c5016a28122d4e03e4954814d457a276c9af24526" + "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 ade8ba43..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.3", + "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 3691364f..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 @@ -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..edbff2ba 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). @@ -62,7 +66,8 @@ no-op. The daemon auto-starts with new sessions (disable via (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: @@ -82,6 +87,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..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.3", + "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 650f55a3..edbff2ba 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). @@ -62,7 +66,8 @@ no-op. The daemon auto-starts with new sessions (disable via (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: @@ -82,6 +87,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/__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(); }); 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..db10ce5c 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 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/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[] = []; 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..bf6da350 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[] }; @@ -24,16 +25,16 @@ 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=立即发)。 * 以下三个会改变"没装 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/daemon/__tests__/bootstrap.test.ts b/src/kernel/daemon/__tests__/bootstrap.test.ts index f8fa4d9e..ccb70bcb 100644 --- a/src/kernel/daemon/__tests__/bootstrap.test.ts +++ b/src/kernel/daemon/__tests__/bootstrap.test.ts @@ -7,6 +7,8 @@ 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'; +import { mdToTelegramHtml } from '../../../adapters/im/telegram-html.js'; // #45 — robustness helpers for this file's "held request" pattern // (const pending = request(...); …asserts…; await pending). On a slow/jittery @@ -488,6 +490,41 @@ describe('AskUserQuestion remote card (Task 9)', () => { expect(sent).toHaveLength(0); // …IM stays silent }); + it('a notify for a session the daemon has never seen still carries the "