diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml new file mode 100644 index 0000000000..b63d7d3093 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383 +2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md new file mode 100644 index 0000000000..8c48194892 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -0,0 +1,62 @@ +# Agent Note: Launcher-owned session identity and exit line + +Status: implemented + +English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) + +## Problem + +Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. + +Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". + +Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. + +A config key cannot express these facts safely, because the deployment is not the authority on them. + +## Decision + +Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`. + +`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config: + +- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh. +- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing. + +`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. + +**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. + +The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released. + +The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI. + +## Alternatives considered + +**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. + +**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate. + +**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. + +**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say. + +**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing. + +**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot. + +## Consequences + +- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release. +- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider. +- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check. +- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI. +- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`. + +The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. + +Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md new file mode 100644 index 0000000000..8811301798 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -0,0 +1,62 @@ +# Agent Note:由启动器持有的会话身份与退出行 + +Status: implemented + +[English](2026-07-28-launcher-owned-resume-identity.md) | 中文 + +## Problem + +有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 + +把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 + +两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 + +一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 + +## Decision + +会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。 + +`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置: + +- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。 +- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 + +`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 + +**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 + +该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。 + +TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。 + +## Alternatives considered + +**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 + +**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。 + +**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 + +**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。 + +**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。 + +**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。 + +## Consequences + +- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。 +- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。 +- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。 +- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。 +- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 + +承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 + +在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 3df1059e1f..bba253325c 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 +2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index a3f3d6b51e..bc6241e925 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -24,7 +24,7 @@ The TUI rebuilds the transcript from the active `session.surface` and reprojects Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. The selector carries a filter box above the list: typing narrows the rows to a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the selection on the previously highlighted row when it survives the filter; Escape clears a non-empty filter before a second Escape cancels the selector. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6a0c2f1281..0f5306ff54 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -24,7 +24,7 @@ TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在 agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。选择器在列表上方设有一个过滤框:输入内容会按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在原先高亮行仍通过过滤时保持其选中状态;Escape 会先清空非空的过滤内容,再次按 Escape 才取消选择器。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index d470b61414..ea5ef87849 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b -2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14 +2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 86f62e16f5..c8cb855378 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,17 +10,17 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace scope line, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It orders candidates by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. The picker opens on the current workspace and reaches every other one through the scope toggle the [cross-workspace resume](2026-07-28-cross-workspace-resume.md) note owns. -`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, workspace, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. -After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume` with the validated id and the target workspace. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. -`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. +The exit line is a launcher-owned context slot rather than a config template, and a host without in-place handoff reports that the session stays resumable instead of naming a command it cannot construct; the [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) note owns that ownership move and supersedes the `resumeCommand` config key this note originally shipped. The TUI still never executes shell text. ## Alternatives considered -**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. +**Have the TUI spawn the resume command.** Rejected: the text is display copy, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. **Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index 06e58f8144..2a7e74d110 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace 作用域行、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间排列候选项,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。选择页打开时位于当前 workspace,并通过[跨 workspace 恢复](2026-07-28-cross-workspace-resume.md)记录所拥有的作用域切换到达其他每一个 workspace。 -`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、workspace、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 -预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并以经过验证的 id 和目标 workspace 调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 -`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 +退出时打印的行是启动器拥有的上下文插槽,而非配置模板;不支持原地交接的宿主会说明会话仍可恢复,而不再给出它无法构造的命令。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md)记录了这次所有权迁移,并取代本记录最初交付的 `resumeCommand` 配置键。TUI 仍然绝不执行 shell 文本。 ## Alternatives considered -**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 +**让 TUI 创建恢复命令进程。** 否决:该文本是展示用文案,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 **在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml index 4532ab2148..d8ac4d0dca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 -2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0 +2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md index 4008f23a3f..f8815c6c19 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -16,7 +16,7 @@ The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPr Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers. -The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. +The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `queued`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `queued` — the steering-queue badge, unavailable unless a running turn has queued messages — and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md index daf15b54d7..831471860a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -16,7 +16,7 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp 注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 -内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`queued`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `queued`——转向队列徽标,仅在运行中的一轮有排队消息时才可用——与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml new file mode 100644 index 0000000000..cbac9d1251 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717 +2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md new file mode 100644 index 0000000000..b6f0b0cc85 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -0,0 +1,61 @@ +# Agent Note: tmux-location context + +Status: implemented + +English | [中文](2026-07-27-tmux-location-context.zh.md) + +## Problem + +An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. + +tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. + +## Decision + +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. + +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. + +**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. + +**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. + +**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. + +**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. + +### Text + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. + +### Durability and request reconstruction + +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. + +The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. + +## Consequences + +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone. + +## Testing + +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. + +## Alternatives considered + +- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. +- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. +- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. +- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. +- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". +- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. +- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. +- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. +- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md new file mode 100644 index 0000000000..e79214e032 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -0,0 +1,61 @@ +# Agent Note:tmux 位置上下文 + +Status: implemented + +[English](2026-07-27-tmux-location-context.md) | 中文 + +## 问题 + +运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 + +tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 + +## 决策 + +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 + +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 + +**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 + +**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 + +**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 + +**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 + +### 文本 + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 + +### 持久性与请求重建 + +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 + +发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 + +## 后果 + +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。 + +## 测试 + +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 + +## 考虑过的替代方案 + +- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 +- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 +- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 +- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 +- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 +- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 +- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 +- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 +- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml index 820645dc95..ed6406245e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md -2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 -2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 +2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38 +2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md index 0868d8dcaf..13f5e7fec1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -24,7 +24,7 @@ The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src **Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc. -**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. +**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. The body's flat default-foreground styling was later revisited: the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md) recesses the whole body into one dim tone under this note's colored status header. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md index 71db5fc6fc..85f9f1244a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -24,7 +24,7 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre **把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 -**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。正文原本以默认前景色平铺,这种样式后来也经过调整:[整合后的 TUI 呈现](../architecture/2026-07-28-consolidated-tui-presentation.md)把整个正文收进本文所述彩色状态标题之下的同一种暗色调。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml new file mode 100644 index 0000000000..60285a26b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df +2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md new file mode 100644 index 0000000000..09b638398e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -0,0 +1,52 @@ +# Agent Note: Cross-workspace session resume + +Status: implemented + +English | [中文](2026-07-28-cross-workspace-resume.zh.md) + +## Problem + +`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. + +Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. + +The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused. + +Finally, resume never changed directory. The host re-execs `dsh --resume=` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project. + +## Decision + +The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory. + +**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. + +`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment. + +**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. + +`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact. + +**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace. + +## Alternatives considered + +**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact. + +**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up. + +**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume ` from that directory, but no longer appear in `/resume`. + +**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones. + +**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam. + +## Consequences + +- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. +- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope. +- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it. +- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here. + +## Testing + +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md new file mode 100644 index 0000000000..5a2e7d2535 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 跨 workspace 会话恢复 + +Status: implemented + +[English](2026-07-28-cross-workspace-resume.md) | 中文 + +## Problem + +`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。 + +存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 + +接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。 + +最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。 + +## Decision + +dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 + +**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 + +`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。 + +**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 + +因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。 + +**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。 + +## Alternatives considered + +**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。 + +**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。 + +**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume ` 仍可恢复,只是不再出现在 `/resume` 中。 + +**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。 + +**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。 + +## Consequences + +- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 +- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 +- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 +- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 + +## Testing + +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml new file mode 100644 index 0000000000..7e8ca82fae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47 +2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md new file mode 100644 index 0000000000..338629f5a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -0,0 +1,43 @@ +# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill + +Status: implemented + +English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md) + +## Problem + +Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type `/skill:dsh-migrate` or `/skill:dsh-upgrade` as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step. + +## Decision + +`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. + +The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. + +**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. + +**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. + +The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. + +## Testing + +`apps/cli/tests/args.spec.ts` gains routing for `migrate`/`upgrade` (bare discriminant) and exit-1 for every leaked option on either side of each subcommand. + +`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. + +No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. + +## Alternatives considered + +**Prefill the input and let the user press Enter.** Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses `invokeSkill` and delivers the intended one-command entry. + +**Seed a natural-language instruction ("use the dsh-migrate skill…") instead of `/skill:`.** Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill. + +**Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn. + +**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged. + +## Consequences + +Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The [TUI skill slash command](2026-07-21-tui-skill-slash-command.md) remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md new file mode 100644 index 0000000000..a9a8a70212 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -0,0 +1,43 @@ +# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 + +Status: implemented + +[English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文 + +## 问题 + +有两个反复出现的流程都以用户手动调用某个 skill 并回答其问题开始:从其他编码 agent 迁移,以及升级本 checkout。二者都要求用户知道该 skill 存在,并把 `/skill:dsh-migrate` 或 `/skill:dsh-upgrade` 作为会话首轮键入。一个专用入口命令若能让用户直接进入该引导式会话,便可省去这一发现步骤。 + +## 决策 + +`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 + +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 + +**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 + +**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 + +`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 + +## 测试 + +`apps/cli/tests/args.spec.ts` 新增 `migrate`/`upgrade` 的路由(裸判别式),以及每个子命令两侧任一泄漏选项的退出码 1。 + +`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 + +无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 + +## 考虑过的替代方案 + +**预填输入框并让用户按回车。** 已否决:需要新增编辑器预填 seam,且仍需一次按键。自动提交复用 `invokeSkill`,实现预期的一命令入口。 + +**播种自然语言指令(“使用 dsh-migrate skill……”)而非 `/skill:`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。 + +**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。 + +**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。 + +## 后果 + +迁移或升级从任何位置都只需一条命令,且引导 skill 已被调用。启动器→TUI 的初始 skill 槽可被未来任何引导式会话命令复用;TUI 的契约是“在启动时调用一次这个具名 skill”,而新鲜性/恢复策略留在拥有会话身份的启动器一侧。[TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)仍是该机制;本 note 在其之上新增了一个由启动器驱动的自动调用,并未取代它。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml new file mode 100644 index 0000000000..1a7c941216 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff +2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md new file mode 100644 index 0000000000..d65e0e6ff0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -0,0 +1,49 @@ +# Agent Note: `dsh meta` boots the TUI over the harness checkout + +Status: implemented + +English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) + +## Problem + +`dsh` treats the invoking directory as the workspace, which is what makes it useful on arbitrary projects. Working on dsh itself therefore means `cd`-ing to the checkout first — and the checkout is not a memorable path: the source install keeps it under a container directory as a timestamped staging worktree (`~/.dsh/source/staging-`) behind a `current` symlink, so the target moves on every upgrade. The agent is already *told* where its source lives by the `harness:source` prompt section, and the `cordis` toolset can modify that runtime, but the human still had to locate the directory by hand to start a session there. + +## Decision + +`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. + +The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. + +The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. + +`meta` accepts only `--resume `. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session. + +**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options. + +## Testing + +`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume `, and `--resume meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`. + +There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. + +The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. + +`dsh meta --resume ` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it. + +## Alternatives considered + +**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. + +**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape. + +**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. + +**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume ` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout. + +## Consequences + +Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace. + +The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume ` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd. + +`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md new file mode 100644 index 0000000000..72a0c65e6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -0,0 +1,49 @@ +# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI + +Status: implemented + +[English](2026-07-28-dsh-meta-source-workspace.md) | 中文 + +## Problem + +`dsh` 把调用目录视为 workspace,这正是它能作用于任意项目的原因。但因此,开发 dsh 自身就得先 `cd` 到检出目录——而该目录并不是一个好记的路径:源码安装会把它放在一个容器目录下、作为带时间戳的 staging 工作树(`~/.dsh/source/staging-`),并由 `current` 符号链接指向,因此每次升级后目标都会变化。`harness:source` 提示词段已经*告知* agent 其源码位置,`cordis` 工具集也能修改该运行时,但人类仍需手工定位该目录才能在其中开始会话。 + +## Decision + +`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 + +目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 + +机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 + +`meta` 只接受 `--resume `。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。 + +**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。 + +## Testing + +`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume ` 与 `--resume meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。 + +该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 + +取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 + +`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。 + +## Alternatives considered + +**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 + +**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。 + +**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 + +**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。 + +## Consequences + +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。 + +恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。 + +`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml new file mode 100644 index 0000000000..eb4893717e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md +2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e +2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md new file mode 100644 index 0000000000..02343c83cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md @@ -0,0 +1,65 @@ +# Agent Note: live-session registry and `dsh list-sessions` + +Status: implemented + +English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md) + +## Problem + +Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages. + +## Decision + +`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam. + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one. + +### Liveness is derived, never stored + +`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped. + +### Two independent concurrency layers + +The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms. + +Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it. + +### Records carry their own title + +The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend. + +### Subagents are invisible by construction + +Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed. + +## Alternatives considered + +**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional. + +**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead. + +**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own. + +**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool. + +**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite. + +**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path. + +**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`. + +## Consequences + +The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next. + +Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time. + +`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only. + +Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement. + +## Testing + +Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch. + +Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote. diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md new file mode 100644 index 0000000000..722ac45f2e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 活跃会话注册表与 `dsh list-sessions` + +Status: implemented + +[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文 + +## 问题 + +没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。 + +## 决策 + +`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。 + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。 + +### 存活状态是推导出来的,绝不存储 + +`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。 + +### 两层相互独立的并发机制 + +该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。 + +跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。 + +### 记录自带标题 + +标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。 + +### subagent 在设计上就不可见 + +只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。 + +## 考虑过的替代方案 + +**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。 + +**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。 + +**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。 + +**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。 + +**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。 + +**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。 + +**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。 + +## 后果 + +注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。 + +标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。 + +`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。 + +存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。 + +## 测试 + +单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。 + +已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。 diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml new file mode 100644 index 0000000000..91227d80e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md +2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323 +2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md new file mode 100644 index 0000000000..8452006190 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard denies direct staging-checkout edits + +Status: implemented + +English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md) + +## Problem + +The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree. + +Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point. + +## Decision + +`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition. + +### Git identity from files, not a path prefix and not `git` + +Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it. + +Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create. + +A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied. + +Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file. + +`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities. + +### Satisfaction replayed from the durable log + +The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals. + +The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness. + +### Fail open, deliberately + +A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake. + +### Narrow scope + +`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent. + +## Alternatives considered + +- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text. +- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins. +- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail. +- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection. +- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured. +- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value. +- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit. +- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate. + +## Consequences + +The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order. + +Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match. + +What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill. + +The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it. + +An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures. + +## Related + +- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself. +- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses. +- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take. diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md new file mode 100644 index 0000000000..83589ce833 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard 拒绝直接编辑 staging 检出目录 + +Status: implemented + +[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文 + +## Problem + +[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。 + +提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。 + +## Decision + +`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。 + +### 从文件而非路径前缀或 `git` 判定 Git 身份 + +系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。 + +只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。 + +路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。 + +有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。 + +`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。 + +已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。 + +### 从持久日志回放满足状态 + +如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。 + +日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。 + +### 刻意采用故障放行 + +目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。 + +### 范围收窄 + +`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。 + +## Alternatives considered + +- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。 +- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。 +- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。 +- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。 +- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。 +- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。 +- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。 +- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。 + +## Consequences + +如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。 + +将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。 + +除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## Testing + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。 + +真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。 + +一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。 + +## Related + +- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。 +- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。 +- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..5241a29b4c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,18 +2,24 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory. + +`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. + +`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible. + The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..69324735cb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,18 +2,24 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 +`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: - 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; -- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。 + +`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 + +`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。 + Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..783fff6487 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,9 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-registry-live": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..02bf596403 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -178,11 +178,11 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 0: computed engineering defaults. The session store defaults to - // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history - // is shared across every cwd, not a project-local ./.sessions. The profile + // Source 0: computed engineering defaults. The session store is the one + // shared root every dsh surface resolves, so history follows the user across + // working directories instead of splitting per project. The profile // (Source 1) overwrites this same field via last-write-wins in put(). - put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) + put('session-persistence-jsonl', 'root', resolveSessionsRoot()) // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { @@ -209,6 +209,7 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) + this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..d143430ed4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,9 +2,9 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ @@ -24,6 +24,39 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + +/** + * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface + * that boots no agent tree — it reads the cross-process session registry and + * exits. `json` selects the machine-readable form over the human table. There + * is no workspace filter: the listing is always every live session, whatever + * directory it runs in. + */ +interface ListSessionsInvocation { + mode: 'list-sessions' + json: boolean +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -45,7 +78,13 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | ListSessionsInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session (list ids with \`dsh ps\`) +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') + .option('--config ', 'start with an alternate plugin configuration file') .action((options: { config?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to @@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + program + .command('list-sessions') + .alias('ps') + .description('list sessions running right now') + .option('--json', 'print the records as a JSON array instead of a table') + .action((options: { json?: boolean }) => { + rejectParentOptions('list-sessions') + resolved = { mode: 'list-sessions', json: options.json === true } + }) + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..8079db1cd4 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -43,6 +43,22 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume) break } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'list-sessions': { + const { runListSessions } = await import('./list-sessions.ts') + await runListSessions(invocation.json) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) + break + } default: invocation satisfies never throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..7ee18c1ea1 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise { port: 0, }) const { ctx, port } = await entry.run() + await registerLiveSessions(ctx) const dispose = async (): Promise => { await ctx.fiber.dispose() } // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts new file mode 100644 index 0000000000..08a972f402 --- /dev/null +++ b/apps/cli/src/list-sessions.ts @@ -0,0 +1,101 @@ +/** + * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now. + * + * A read-only surface: it mounts the session registry alone and never boots an + * agent tree, so listing stays fast and cannot start model work as a side + * effect. Liveness comes from the registry, which prunes records whose process + * is gone, and every displayed field including the title comes from the record, + * so no session log is opened and no backend format is assumed. + * @module @deepseek-ai/dsh/list-sessions + */ + +import { Context } from 'cordis' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import { registryRoot } from './register-session.ts' + +/** Column header text, also the minimum width of each column. */ +const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const + +/** Shown when a session has no title yet. */ +const NO_TITLE = '—' + +/** + * Render milliseconds of uptime as a compact human duration. + * @param ms - elapsed milliseconds since the session registered. + * @returns a short duration such as `12s`, `4m`, or `2h14m`. + */ +export function formatUptime(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)) + if (seconds < 60) return `${String(seconds)}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${String(minutes)}m` + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m` + const days = Math.floor(hours / 24) + const leftoverHours = hours % 24 + return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h` +} + +/** One fully-resolved listing row, in column order. */ +type Row = readonly [string, string, string, string, string] + +/** + * Build the display rows for a listing, newest session first. + * @param records - the live records to render. + * @param now - the current epoch milliseconds uptime is measured against. + * @returns one row per record, each already stringified per column. + */ +export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] { + return [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => [ + record.sessionId, + String(record.pid), + formatUptime(now - record.startedAt), + record.cwd, + record.title ?? NO_TITLE, + ] as const) +} + +/** + * Render rows as a left-aligned table with a header line. + * + * The last column is never padded, so a long title cannot add trailing + * whitespace to every line. + * @param rows - the rows to render, already stringified. + * @returns the complete table text, newline-terminated. + */ +export function renderTable(rows: readonly Row[]): string { + const widths = HEADERS.map((header, column) => + Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0))) + const line = (cells: readonly string[]): string => + cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd() + return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n' +} + +/** + * List live sessions and exit. Prints a table by default, or a JSON array with + * `--json`; an empty listing is a success, not an error. + * @param json - emit the machine-readable JSON array instead of the table. + */ +export async function runListSessions(json: boolean): Promise { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root: registryRoot() }) + const records = await ctx.sessionRegistry.list() + await ctx.fiber.dispose() + + if (json) { + const rows = [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null })) + process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`) + return + } + if (records.length === 0) { + process.stdout.write('no dsh sessions running\n') + return + } + process.stdout.write(renderTable(buildRows(records, Date.now()))) +} diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts new file mode 100644 index 0000000000..ff78456e8a --- /dev/null +++ b/apps/cli/src/register-session.ts @@ -0,0 +1,44 @@ +/** + * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the + * publisher that keeps it in step with this process's sessions. + * + * Both plugins mount on the booted app's own context, so records share that + * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a + * killed process leaves records the next reader prunes by pid. Only top-level + * surfaces a user launches mount this — in-process subagents have no process of + * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` + * rather than this CLI, so neither reaches this path. + * @module @deepseek-ai/dsh/register-session + */ + +import { join } from 'node:path' +import type { Context } from 'cordis' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live' + +/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */ +export const registryRoot = (): string => join(resolveDshHome(), 'run') + +/** + * Publish this process's sessions for the lifetime of `ctx`. + * + * Publication follows session lifecycle rather than a launcher-known id, so one + * path serves every surface identically — the TUI's single session and a + * server's on-demand ones alike — and titles reach the listing as they are + * logged. + * + * Mounting is best-effort: a registry failure must not take down a working agent + * session, because the registry is an observability aid rather than part of the + * agent's contract. Failures warn through the context logger. + * @param ctx - the booted app context whose lifetime the records share. + */ +export async function registerLiveSessions(ctx: Context): Promise { + try { + const scope = ctx.isolate('sessionRegistry') + await scope.plugin(SessionRegistryFile, { root: registryRoot() }) + await scope.plugin(sessionRegistryLive) + } catch (error) { + ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error)) + } +} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..fb1c354d46 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -4,13 +4,20 @@ * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking - * directory: sessions, relative paths, and workspace instructions resolve from - * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the - * agent's system prompt is told the path to this harness checkout so it can find - * its own source. + * directory: the session cwd, relative paths, and workspace instructions resolve + * from it, so `dsh` acts on whatever project it is launched in. Session storage + * is the exception — it lives under the Harness home so `/resume` reaches every + * workspace, and an in-place resume enters the selected session's own directory. + * `dsh meta` + * ({@link runMeta}) is the one exception — it makes this harness checkout the + * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh + * sessions whose first turn auto-invokes a bundled skill. After boot, the + * agent's system prompt is told the path to this harness checkout so it can + * find its own source. * @module @deepseek-ai/dsh/tui */ +import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -19,13 +26,18 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' +import { SessionId } from '@deepseek-ai/dsh-session' import type { Context } from 'cordis' +import { registerLiveSessions } from './register-session.ts' import { + INITIAL_SKILL_KEY, + MAIN_SESSION_ID_KEY, + SESSIONS_ROOT_KEY, TUI_GOODBYE_MESSAGE_KEY, + type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' @@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis // symlink, an arbitrary cwd). The agent is told where its own source lives. const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its + * shared session-store root, `sessions` under the Harness home. Shared-store + * policy is the launcher's alone — the app bundle treats the slot as opaque and + * keeps a project-local fallback, so only `dsh` decides that sessions are + * shared across working directories (making `/resume` and `list-sessions` span + * every workspace). + * @returns the absolute session-store root this launcher shares. + */ +export function launcherSessionsRoot(): string { + return resolveSessionsRoot() +} + /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; the tui-agent PTY smoke drives this path end to end, personal overlay included */ +/** + * Run the interactive TUI with this harness checkout as the workspace + * (`dsh meta`), whatever directory it was launched from. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from + * an ordinary `dsh` run in another directory is not found here. + */ +export async function runMeta(resumeSessionId: string | undefined): Promise { + return runTui(undefined, resumeSessionId, SOURCE_ROOT) +} + +/** + * Run the interactive TUI as a guided fresh session whose first turn invokes a + * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`). + * Always mints a fresh session in the invoking directory; the skill is seeded + * only on this first launch, so a later `--resume` of the session is an ordinary + * TUI session with no re-injection. + * @param skill - the bundled skill name to auto-invoke as the first turn. + */ +export async function runSkillSession(skill: string): Promise { + return runTui(undefined, undefined, undefined, skill) +} + /** * Run the interactive TUI from the invoking directory. * @param config - a config path to boot instead of the shipped default, or * `undefined` for the default; already parsed from `--config`. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * already parsed and non-empty-validated from `--resume`. It is provided on the - * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config - * reads through `!!js` to rehydrate that session. + * @param resumeSessionId - a persisted session id to resume, or `undefined` to + * mint a fresh one; already parsed and non-empty-validated from `--resume`. + * Either way the resulting identity reaches the booted app through + * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session. + * @param workspace - a directory to make the workspace instead of the invoking + * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. + * @param initialSkill - a bundled skill to auto-invoke as a fresh session's + * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored + * on a resume, so it never re-fires; reaches the app through + * {@link INITIAL_SKILL_KEY}. */ -export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { +export async function runTui( + config: string | undefined, + resumeSessionId: string | undefined, + workspace?: string, + initialSkill?: string, +): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. @@ -66,28 +125,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + // Both .env layers are loaded, so switching the workspace here cannot alter + // environment precedence. The cwd IS the workspace seam: the shipped config + // resolves the session cwd and the HMR watch root from it, so one chdir moves + // both together. Sessions themselves live under the Harness home so `/resume` + // spans every workspace, and are unaffected by this chdir. + if (workspace !== undefined) process.chdir(workspace) process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` - // flag, so the resumed process rehydrates through this same intake. The host - // is offered only when Node exposes `process.execve` and knows its own entry. + // flag, so the resumed process rehydrates through this same intake. The + // selected session may belong to another workspace, so the handoff also enters + // that directory. The host is offered only when Node exposes `process.execve` + // and knows its own entry. const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - const resumeCommand = (sessionId: string): string => - `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}` + // Resuming reproduces THIS invocation with a different id. Meta mode is a + // subcommand that rejects `--config`, while the default surface carries it, so + // both the in-place handoff and the printed command derive from one shape. + // `meta` is only reproducible for a target inside this checkout: it chdirs to + // SOURCE_ROOT itself, which would override any other workspace, so a + // cross-workspace resume takes the default surface and the caller supplies the + // directory instead. + const resumeArgs = (sessionId: string, targetCwd?: string): string[] => + workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) + ? ['meta', `--resume=${sessionId}`] + : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []] + // Mint the fresh id here rather than in the app bundle: the exit line names + // the session to resume, so the launcher must know it before the tree boots. + const identity: MainSessionIdentity = resumeSessionId === undefined + ? { id: SessionId(`main-session-${randomUUID()}`), resume: false } + : { id: SessionId(resumeSessionId), resume: true } + const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}` const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { async handoff(sessionId, cwd): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) - // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, - `--resume=${sessionId}`, - ...config !== undefined ? ['--config', config] : [], + ...resumeArgs(sessionId, cwd), ] + // `execve` inherits the cwd, and the target session may belong to another + // workspace. Enter it BEFORE teardown commits: an unreachable directory + // (deleted, unreadable) must reject while the caller can still restore the + // terminal, and a chdir after disposal would have no owner to report to. try { process.chdir(cwd) } catch (error) { @@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME), (hostCtx) => { - // Inject the resume id (or undefined) so the shipped config's `!!js` - // reads it as a bare identifier; then offer the in-place handoff host. - hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) - if (resumeSessionId !== undefined) { - hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`) - } + // The launcher owns session identity and the exit line: a config-mounted + // app bundle reads both from these slots, so no cordis.yml key can drop + // resume. + hostCtx.provide(MAIN_SESSION_ID_KEY, identity) + hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) + // Shared-store policy is the launcher's: sessions live in one root under + // the Harness home across every cwd, so /resume and list-sessions see + // every workspace. The bundle treats the slot as opaque. + hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + // Seed the first turn only for a fresh session, so resuming never + // re-invokes the skill. + if (initialSkill !== undefined && resumeSessionId === undefined) { + hostCtx.provide(INITIAL_SKILL_KEY, initialSkill) + } }, ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) + // Publication follows the store; meta mode already chdir'd, so each session + // reports its own cwd. + await registerLiveSessions(ctx) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..f4ad016005 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) @@ -40,6 +41,7 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() + await registerLiveSessions(ctx) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..99569c7cf0 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,33 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) + // `list-sessions` has one option and no workspace filter: the listing is always + // global. `ps` is its alias and resolves to the same mode. + expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false }) + expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true }) + expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +71,27 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) + // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a + // mistyped invocation, not a listing with a silently dropped input. + expect(exitCode(['ps', '--resume', 's'])).toBe(1) + expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1) + expect(exitCode(['--resume', 's', 'ps'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..71c8257b08 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that + * boots no agent tree, so the built bin is the whole product path. + * Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ -async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin], { +/** + * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + * + exit code. `env` isolates the Harness home for surfaces that read it. + */ +async function runBuiltBin( + args: readonly string[] = [], + env: Record = {}, +): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, + env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) + + describe('dsh list-sessions', () => { + let home: string + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) }) + afterEach(() => { rmSync(home, { recursive: true, force: true }) }) + + it('reports an empty listing as success, not an error', async () => { + const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout.trim()).toBe('no dsh sessions running') + expect(stderr).toBe('') + }, 30_000) + + it('emits an empty JSON array for machines', async () => { + const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(JSON.parse(stdout)).toEqual([]) + }, 30_000) + + it('runs without a TTY, unlike the TUI surface', async () => { + // The listing is read-only and boots no agent tree, so piped stdio — the + // launch the TUI refuses — is a supported way to run it. + const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stderr).not.toContain('interactive TTYs') + }, 30_000) + + it('rejects a leaked default-surface flag instead of listing', async () => { + const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home }) + expect(code).not.toBe(0) + expect(stderr).toContain('list-sessions takes none of') + }, 30_000) + }) }) diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts new file mode 100644 index 0000000000..a73faf1b20 --- /dev/null +++ b/apps/cli/tests/list-sessions.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building + * (newest first, absent-title placeholder) and table alignment without trailing + * padding. Every displayed field comes from the record, so there is no log + * reading to cover here. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts' + +function record(overrides: Partial = {}): SessionRegistryRecord { + return { + sessionId: SessionId('sess-1'), + pid: 4242, + cwd: '/work/project', + startedAt: 1_000, + bootId: BootId('boot-1'), + ...overrides, + } +} + +describe('formatUptime', () => { + it.each([ + [0, '0s'], + [999, '0s'], + [12_000, '12s'], + [59_999, '59s'], + [60_000, '1m'], + [3_540_000, '59m'], + [3_600_000, '1h'], + [8_040_000, '2h14m'], + [86_400_000, '1d'], + [90_000_000, '1d1h'], + ])('renders %ims as %s', (ms, expected) => { + expect(formatUptime(ms)).toBe(expected) + }) + + it('never renders a negative duration for a clock that moved backwards', () => { + expect(formatUptime(-5_000)).toBe('0s') + }) +}) + +describe('buildRows', () => { + it('orders newest first and marks a missing title', () => { + const rows = buildRows([ + record({ sessionId: SessionId('older'), startedAt: 1_000 }), + record({ sessionId: SessionId('newer'), startedAt: 5_000 }), + ], 65_000) + expect(rows.map(row => row[0])).toEqual(['newer', 'older']) + expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—']) + }) +}) + +describe('renderTable', () => { + it('aligns columns and leaves no trailing whitespace', () => { + const table = renderTable(buildRows([ + record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }), + record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }), + ], 1_000)) + const lines = table.split('\n') + expect(lines[0]).toMatch(/^SESSION {18}\s+PID/) + for (const line of lines) expect(line).toBe(line.trimEnd()) + // The header and every row align on the same column starts. + const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID') + expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? '')) + expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? '')) + }) + + it('renders a header even with no rows, so the columns stay discoverable', () => { + expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n') + }) +}) diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts new file mode 100644 index 0000000000..8f12455f1b --- /dev/null +++ b/apps/cli/tests/sessions-root.spec.ts @@ -0,0 +1,20 @@ +/** + * Pins the launcher side of the shared-session-store contract: `dsh` defaults + * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The + * plugin side — the slot treated as opaque, explicit config winning, and a + * project-local fallback with no globality assumption — is pinned by + * `packages/examples/tui-demo/tests/tui-agent.spec.ts`. + */ + +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { launcherSessionsRoot } from '../src/tui.ts' + +afterEach(() => vi.unstubAllEnvs()) + +describe('launcherSessionsRoot', () => { + it('defaults the boot slot to sessions under DSH_HOME', () => { + vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home') + expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions'))) + }) +}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..ef0a6705ad 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,15 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-registry/session-registry" + }, + { + "path": "../../packages/session-registry/session-registry-file" + }, + { + "path": "../../packages/session-registry/session-registry-live" + }, { "path": "../../packages/client/connection" }, diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 43f5b20716..6de0f7ff6e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,6 +47,10 @@ flowchart LR pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] pkg_apiproxy["apiproxy"] + pkg_session_registry["session-registry"] + svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -197,6 +201,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_registry --> svc_sessionRegistry + pkg_session_registry_file --> svc_sessionRegistry pkg_session_telemetry --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle @@ -279,6 +285,7 @@ flowchart LR svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui + svc_sessionRegistry --> pkg_session_registry_live svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -339,6 +346,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | +| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..8bd6e966b9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1153,6 +1153,26 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-registry-file` + +```ts config-catalog +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} +``` + +Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) + ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1263,6 +1283,38 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) +## `@deepseek-ai/dsh-source-guard` + +Requires: `fs` + +```ts config-catalog +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} +``` + +Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts) + ## `@deepseek-ai/dsh-spill-local` ```ts config-catalog @@ -1541,6 +1593,20 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-tmux-context` + +Requires: `agents` + +```ts config-catalog +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) + ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -1960,7 +2026,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -1968,13 +2039,6 @@ export interface Config { sessionReferences?: SessionReferenceConfig /** TUI transcript's optional first line; absent renders nothing on start. */ welcome?: string - /** - * Shell command template the TUI prints on exit and lists under `/resume`, - * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -1985,8 +2049,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -1994,7 +2056,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -2231,6 +2293,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2253,6 +2316,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..7010113be7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1464,6 +1464,44 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam) + +Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +```ts cordis-catalog +/** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ +abstract register(registration: SessionRegistration): Promise<() => Promise> + +/** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ +abstract retitle(sessionId: SessionId, title: string): Promise + +/** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ +abstract list(): Promise +``` + +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts) + ## `ctx.sessions` — `SessionStore` In-memory session store (`ctx.sessions`). diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 5072c2684d..74392c18ef 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -116,6 +116,7 @@ | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | +| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 | | fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | | fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash | | finish reason | 结束原因 | | | | diff --git a/docs/module-graph.md b/docs/module-graph.md index d3e60978e7..777457020d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -173,6 +173,7 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] + pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -184,6 +185,7 @@ flowchart TD end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] + pkg_source_guard["source-guard"] end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] @@ -221,6 +223,11 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_session_registry["packages/session-registry"] + pkg_session_registry["session-registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -450,6 +457,9 @@ flowchart TD pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_session_registry --> pkg_brand + pkg_session_registry --> pkg_invariants + pkg_session_registry --> pkg_session pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm @@ -523,6 +533,10 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -533,6 +547,9 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain + pkg_session_registry_file --> pkg_invariants + pkg_session_registry_file --> pkg_session + pkg_session_registry_file --> pkg_session_registry pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -614,6 +631,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session pkg_pty_local --> pkg_subprocess + pkg_session_registry_live --> pkg_invariants + pkg_session_registry_live --> pkg_session + pkg_session_registry_live --> pkg_session_registry + pkg_session_registry_live --> pkg_session_title pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -776,6 +797,13 @@ flowchart TD pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools + pkg_source_guard --> pkg_agent + pkg_source_guard --> pkg_fs + pkg_source_guard --> pkg_invariants + pkg_source_guard --> pkg_llm + pkg_source_guard --> pkg_sandbox + pkg_source_guard --> pkg_session + pkg_source_guard --> pkg_tools pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp @@ -1045,6 +1073,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1062,9 +1091,11 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1080,6 +1111,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | +| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1107,6 +1139,7 @@ flowchart TD | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 83144b2153..601ac94419 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -60,7 +60,6 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml new file mode 100644 index 0000000000..a8a83302bb --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml @@ -0,0 +1,38 @@ +# Test-only composition: the model attempts one `write` into a staging-shaped +# git fixture, so the guard's denial is observed through the real Loader and app. +- id: source-guard-mock-llm + name: './mock-llm.ts' + +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: fs + name: '@deepseek-ai/dsh-fs-local' + +# Read-before-edit policy: without it the write would resolve `createIfAbsent` +# and the transcript would not show the guard as the sole reason for refusal. +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +# Mounts the guard with `protectedCheckout` resolved against the process cwd, so +# it arms for the staging fixture the smoke builds there rather than for the +# checkout running the test (the config default is this module's own location). +- id: source-guard-fixture + name: './mount-guard.ts' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: source-guard-mock + model: source-guard-mock + persona: 'Test the source guard.' + persistenceRoot: './.sessions' + persistenceCompression: none + workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts new file mode 100644 index 0000000000..44baccd617 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */ +const TARGET = resolve('staging/guarded.ts') + +/** + * Two-step adapter for the source-guard Loader fixture: the first step calls + * `write` on the staged file, the second closes the turn once a tool result has + * come back, so the transcript records what the model received. + */ +class SourceGuardMockAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const alreadyCalled = options.messages.some(message => message.content.some( + block => block.type === 'tool-result', + )) + if (alreadyCalled) { + const text = 'denied as expected' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + const callId = CallId('source-guard-write') + const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +export const name = 'source-guard-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `source-guard-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts new file mode 100644 index 0000000000..90a89a2897 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts @@ -0,0 +1,14 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' + +export const name = 'source-guard-fixture' + +/** + * Mount the real guard against the staging fixture in the process cwd. The + * checkout under protection is a runtime fact of the isolated smoke directory, + * which no static config value can name. + */ +export async function apply(ctx: Context): Promise { + await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') }) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts new file mode 100644 index 0000000000..2fd6d0f5ec --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tmux-context driver requires a config path') + +const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts new file mode 100644 index 0000000000..3e9540abff --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command + * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, + * so the injected tmux location is stable without a real tmux server. `start()` + * throws — tmux-context must never spawn a background process. + */ +class TmuxMockBash extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + + override run(_spec: BashExecSpec): Promise { + const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: `${line}\n`, truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +export const name = 'tmux-context-mock-bash' + +/** Register the deterministic `ctx.bash` executor for the fixture. */ +export function apply(ctx: Context): void { + ctx.plugin(TmuxMockBash) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts new file mode 100644 index 0000000000..2f6c7a6408 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the tmux-context Loader fixture. */ +class TmuxContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'tmux context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'tmux-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `tmux-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml new file mode 100644 index 0000000000..419922a0e3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml @@ -0,0 +1,21 @@ +# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. +# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location +# is stable without a real tmux server on the test host. +- id: tmux-context-mock-llm + name: './tmux-context-mock-llm.ts' + +- id: bash + name: './tmux-context-mock-bash.ts' + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: tmux-context-mock + model: tmux-context-mock + persona: 'Test the tmux-context plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..b6f06057e3 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ea8695d37e..eae5994fd8 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -29,7 +29,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume ` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 46d982794c..eec54fe6ad 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -10,9 +10,7 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 86129d3fa4..1bd1b7298c 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -59,6 +59,8 @@ flowchart LR cfg --> plugin_tui_fs_policy plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] cfg --> plugin_tui_tool_fs + plugin_tui_source_guard["source-guard
@deepseek-ai/dsh-source-guard"] + cfg --> plugin_tui_source_guard plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] @@ -93,6 +95,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `source-guard` | `@deepseek-ai/dsh-source-guard` | | `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2b14271c96..b3f31ee928 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -47,15 +47,11 @@ config: provider: deepseek model: deepseek-v4-pro - # `dsh --resume ` provides the session id on the boot context (the ids - # live under ./.sessions); with no flag the identifier is undefined and a - # fresh session starts each run. The typeof guard tolerates a launcher that - # never provides the slot, reading undefined rather than throwing. - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - # Printed on exit and listed by `/resume`; `{session}` fills the live id. - # `dsh --resume ` resumes that session, so run it from this cwd. - resumeCommand: 'dsh --resume {session}' + # Session identity and the resume command printed on exit are launcher-owned: + # `dsh` provides both on the boot context, so `--resume ` and the exit + # hint need no key here. `persistenceRoot` is omitted: the dsh launcher + # supplies its shared Harness-home store through a boot slot, and a bare + # example boot falls back to the bundle's project-local `./.sessions`. workspaceContext: maxBytes: 65536 ui: @@ -169,6 +165,15 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# Refuses write/edit inside the dsh checkout this launcher runs from, on that +# checkout's own branch, until the session loads dsh-customize — the skill whose +# workflow (task worktree, then integrate under the staging lock) the refusal +# points at. Inert everywhere else: another repository, a task worktree nested +# under the protected one, a sibling checkout on a different branch, and any +# workspace outside a dsh source install all pass through untouched. +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + # Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the # local bash executor above — not ctx.fs. Capped results save the complete # formatted list through the spill backend below (ctx.spillStore, optional). diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..79c4f0504a 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -20,6 +20,13 @@ const SKILL_BLOCK_OPEN = '' const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER' const SKILL_RECEIVED_TEXT = 'Scripted skill body received.' const TITLE_TEXT = 'scripted session title' +// The failing-bash scenario proves the terminal card reports a non-zero exit +// exactly once: the model-facing result carries the `[exit code: N]` marker, and +// the card turns it into its own `[exit N]` pill instead of showing both. +const BASH_FAILURE_PROBE = 'Run the failing scripted command.' +const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3' +const BASH_FAILURE_TEXT = 'Scripted bash failure observed.' +const BASH_FAILURE_CALL_ID = CallId('call-bash-failure') function textChunks(text: string): StreamChunk[] { return [ @@ -108,9 +115,23 @@ class ScriptedTuiAdapter extends LlmAdapter { return } - const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false - if (hasToolResult) { - for (const chunk of textChunks(FINAL_TEXT)) yield chunk + const blocks = lastMessage?.content ?? [] + if (blocks.some(block => block.type === 'tool-result')) { + const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + return + } + if (lastText.includes(BASH_FAILURE_PROBE)) { + const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs } + yield { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } return } diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index a32bdf987b..e615a7ec09 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -33,8 +33,6 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..5b1ccdff75 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -365,11 +365,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('must be a top-level YAML array of loader patch entries') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { - // The flag path end to end: apps/cli parses `--resume missing-session` and - // provides the id on the boot context, the shipped config's `!!js` reads it - // as a bare identifier, and the resume fails loud — proving the printed - // `dsh --resume ` hint reaches the config resume intake with no env var. + it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { + // The flag path end to end: apps/cli parses `--resume missing-session`, + // provides it as the launcher-owned identity on the boot context, and the + // resume fails loud — proving the printed hint reaches the app's resume + // intake with no config key and no environment variable. const output = await smoke({ label: 'dsh resume flag failure', tempDirPrefix: 'dsh-resume-flag-', @@ -380,6 +380,70 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the launcher-owned resume command on exit, naming the booted config', async () => { + // The exit line is built by apps/cli from this invocation, so it must carry + // `--config`: a hint that omitted it would resume into the default tree. + const output = await smoke({ + label: 'dsh goodbye message', + tempDirPrefix: 'dsh-goodbye-', + binScript: dshBinScript, + configPath: scriptedConfigPath, + actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => { + // Loader patches replace a targeted `config` key wholesale, so a personal + // overlay that omits a resume key used to silently disable the exit hint. + // Launcher-owned identity and exit line make that unreachable. + const output = await smoke({ + label: 'dsh overlay keeps resume', + tempDirPrefix: 'dsh-overlay-resume-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' workspaceContext: false', + ' welcome: OVERLAY REPLACED THE CONFIG.', + '', + ].join('\n'), + }, + }), + actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { + // The model-facing result ends in `[exit code: 3]`, which the terminal card + // consumes into its own `[exit 3]` pill. Rendering both would report the same + // exit twice, so the marker must not survive into the card body. + const output = await smoke({ + label: 'tui-agent bash exit pill', + tempDirPrefix: 'dsh-bash-exit-pill-', + configPath: scriptedConfigPath, + actions: [ + ...SELECT_PRO_MODEL, + { + waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', + send: 'Run the failing scripted command.\r', + }, + { waitFor: 'Scripted bash failure observed.', send: '/exit\r' }, + ], + }) + // The command really ran: its stdout is in the card body. + expect(output).toContain('SCRIPTED_BASH_FAILED') + expect(output).toContain('[exit 3]') + expect(output).not.toContain('[exit code: 3]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. diff --git a/knip.json b/knip.json index 38eb32c223..0d616c8388 100644 --- a/knip.json +++ b/knip.json @@ -33,10 +33,15 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", + "headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts", + "headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", + "headless-agent/tests/fixtures/tmux-context-driver.ts", + "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", + "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -174,6 +179,16 @@ "tests/**/*.ts" ] }, + "packages/context/tmux-context": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", @@ -289,6 +304,26 @@ "tests/**/*.ts" ] }, + "packages/guard/source-guard": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/session-registry/session-registry-file": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/register-once.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/session-query/session-query-sqlite": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/context/README.md b/packages/context/README.md index a5244dfe99..ffe9aaa9e2 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,12 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml new file mode 100644 index 0000000000..ec4562eea4 --- /dev/null +++ b/packages/context/tmux-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md +README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa +README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md new file mode 100644 index 0000000000..5ea36948d6 --- /dev/null +++ b/packages/context/tmux-context/README.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +English | [中文](README.zh.md) + +Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). + +## Config + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one. + +## How it reads tmux + +The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. + +State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing. + +## Timing semantics + +When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). + +## Model Experience + +### Preparation-time tmux location + +#### What the model sees + +On each turn whose tmux state changed, one source-tagged context message with the three lines below. `` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured. + +##### Changed-turn reading + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token effect + +Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps. +- **Own location only** — the plugin never captures the visible text of sibling panes. +- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported. +- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected. +- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md new file mode 100644 index 0000000000..914d8d7c99 --- /dev/null +++ b/packages/context/tmux-context/README.zh.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +[English](README.md) | 中文 + +可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 + +## 配置 + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。 + +## 如何读取 tmux + +插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 + +状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 + +## 时序语义 + +当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 + +## 模型体验 + +### 准备期 tmux 位置 + +#### 模型看到的内容 + +在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 + +##### 变化轮次读数 + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token 影响 + +每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 + +#### KV 缓存影响 + +只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 + +## 已知限制与后续工作 + +- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。 +- **仅自身位置**——插件从不采集相邻 pane 的可见文本。 +- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。 +- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。 +- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json new file mode 100644 index 0000000000..a874ebb08c --- /dev/null +++ b/packages/context/tmux-context/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tmux-context", + "description": "Opt-in durable per-step context with this agent's tmux pane and window location", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts new file mode 100644 index 0000000000..495ab886a0 --- /dev/null +++ b/packages/context/tmux-context/src/index.ts @@ -0,0 +1,227 @@ +/** + * Opt-in request-preparation tmux-location context. Eligible step attempts + * append durable, source-attributed context naming the tmux session, window, + * and pane this agent process runs in, plus the window's pane-tree layout. + * + * The plugin pulls state once per turn, on the first step (`step === 1`), by + * running one `tmux display-message` through the `ctx.bash` executor seam. It + * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by + * matching the pane's `#{pane_tty}` against this process's controlling terminal, + * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor + * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects + * only when the rendered tmux state changes since the last injection (a moved, + * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor + * between injections. Absent tmux environment, an inherited-only environment, + * absent `ctx.bash`, or a failed query is a no-op, never an error. + * + * @module @deepseek-ai/dsh-tmux-context + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tmux-context' + +/** The agent registry that owns the `agent/step` lifecycle seam. */ +export const inject = ['agents'] + +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} + +/** Schemastery validation for {@link Config}. */ +export const Config: z = z.object({ + refreshIntervalMs: z.number(), +}) + +/** + * Tab-separated tmux format fields, in query order. Layout (`window_layout`) + * is the pane-tree description; pane/window pixel sizes are intentionally + * excluded (own location and layout only, per the package scope). + */ +const TMUX_FIELDS = [ + '#{session_name}', + '#{window_index}', + '#{window_name}', + '#{pane_index}', + '#{pane_id}', + '#{window_active}', + '#{pane_active}', + '#{window_layout}', +] as const + +/** Structured tmux location parsed from one `display-message` reading. */ +interface TmuxLocation { + sessionName: string + windowIndex: string + windowName: string + paneIndex: string + paneId: string + windowActive: string + paneActive: string + windowLayout: string +} + +/** Prefix marking the volatile turn/step preamble line of a rendered reading. */ +const READING_PREFIX = 'tmux location (turn ' + +/** + * Field separator between tmux format fields. tmux does not interpret C escapes + * in a format, so the literal two-character sequence `\t` is emitted verbatim + * and split back out here; this avoids embedding raw whitespace in the command. + */ +const FIELD_SEP = '\\t' + +/** + * Read this process's tmux location through the bash seam, or `undefined` when + * this process is not genuinely running inside a tmux pane or the query fails. + * + * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell + * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and + * `$TMUX_PANE` from that ancestor, so the variables are present even though this + * process does not live in that pane. The command therefore also compares the + * pane's `#{pane_tty}` against this process's own controlling terminal + * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, + * an inherited environment names some other pane's tty. Fields are emitted only + * on a match, so an inherited environment reads as "not in tmux" and injects + * nothing. + * + * @param bash - the executor seam used to run the read-only tmux/ps commands. + * @param processId - this agent process's pid, whose controlling tty must match the pane. + * @param signal - abort signal forwarded to the executor. + * @returns the parsed location, or `undefined` when not in a real pane or on any failure. + */ +async function queryTmuxLocation( + bash: BashExecutor, + processId: number, + signal: AbortSignal, +): Promise { + const format = TMUX_FIELDS.join(FIELD_SEP) + const command = [ + '[ -n "$TMUX_PANE" ] || exit 1', + `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`, + '[ -n "$self_tty" ] || exit 1', + 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1', + '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1', + `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`, + ].join('\n') + const spec = bash.resolve({ command, signal }) + const result = await bash.run(spec) + if (result.exitCode !== 0) return undefined + const line = result.stdout.text.split('\n', 1)[0] as string + const parts = line.split(FIELD_SEP) + if (parts.length !== TMUX_FIELDS.length) return undefined + const [ + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + ] = parts as [string, string, string, string, string, string, string, string] + if (paneId.length === 0) return undefined + return { + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + } +} + +/** + * Render the stable tmux state block: the part of a reading compared for + * change suppression. It excludes the turn preamble so re-injection is driven + * only by tmux state, not by loop position. + */ +function renderState(location: TmuxLocation): string { + return `session ${location.sessionName}, ` + + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, ` + + `pane ${location.paneIndex} ${location.paneId}\n` + + `window active=${location.windowActive}, pane active=${location.paneActive}, ` + + `layout ${location.windowLayout}` +} + +/** Render the full durable reading, including the volatile turn preamble. */ +function renderReading(location: TmuxLocation, turn: number): string { + return `${READING_PREFIX}${turn}):\n${renderState(location)}` +} + +/** + * The stable state block of this plugin's latest durable injection, or + * `undefined` when the session has none. Scans raw durable events so the + * schedule survives compaction and resumed processes without process-local + * cache state. + */ +function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + const [block] = event.data.content + if (block?.type !== 'text') return undefined + const newline = block.text.indexOf('\n') + const state = newline === -1 ? '' : block.text.slice(newline + 1) + return { state, time: event.time } + } + } + return undefined +} + +/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ +function validateRefreshInterval(refreshIntervalMs: number | undefined): void { + if (refreshIntervalMs !== undefined && ( + !Number.isSafeInteger(refreshIntervalMs) + || refreshIntervalMs < 0 + )) { + throw new TypeError( + `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, + ) + } +} + +/** + * Register a prepended `agent/step` listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - durable refresh scheduling configuration. + * @throws when the refresh interval is invalid. + */ +export function apply(ctx: Context, config: Config): void { + const refreshIntervalMs = config.refreshIntervalMs + validateRefreshInterval(refreshIntervalMs) + + ctx.on('agent/step', async ( + agent: Agent, + turn: number, + step: number, + signal: AbortSignal, + ): Promise => { + if (signal.aborted || step !== 1) return + const bash = ctx.get('bash') + if (bash === undefined) return + const previous = latestInjectedState(agent) + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { + const now = Date.now() + if (now >= previous.time && now - previous.time < refreshIntervalMs) return + } + const location = await queryTmuxLocation(bash, process.pid, signal) + if (location === undefined) return + const state = renderState(location) + if (previous !== undefined && previous.state === state) return + agent.inject(createUserMessage({ + content: [{ type: 'text', text: renderReading(location, turn) }], + source: { kind: 'plugin', plugin: name }, + })) + }, { prepend: true }) +} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts new file mode 100644 index 0000000000..181f1a2289 --- /dev/null +++ b/packages/context/tmux-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. + * @module @deepseek-ai/dsh-tmux-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' + +/** Cordis companion plugin name. */ +export const name = 'tmux-context-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session + * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts new file mode 100644 index 0000000000..06e3c1f3f5 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.e2e.ts @@ -0,0 +1,77 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// Keep the Loader config under examples so both modes exercise the same deployable +// topology: local fixture source plus bare plugins owned by the examples workspace. +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('tmux-context through a real headless cordis.yml', () => { + it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => { + let events: SessionEvent[] = [] + const { stderr } = await runLoaderSmoke({ + label: 'tmux-context headless smoke', + tempDirPrefix: 'tmux-context-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') + // Two identical-state turns: the location injects once and is suppressed after. + expect(contexts).toHaveLength(1) + + const [reading] = contexts + if (reading === undefined) throw new Error('missing tmux-context reading') + const starts = events.filter(event => event.type === 'step/start') + expect(reading.seq).toBeLessThan(starts[0]!.seq) + expect(reading.surfaceOp).toBe('append') + + const text = reading.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + expect(text).toBe( + 'tmux location (turn 1):\n' + + 'session work, window 0 "editor", pane 1 %3\n' + + 'window active=1, pane active=1, layout a1b2,80x24,0,0,4', + ) + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).not.toContain('tmux location (turn') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts new file mode 100644 index 0000000000..fedd672f57 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' +import type { Config } from '@deepseek-ai/dsh-tmux-context' + +const SIGNAL = new AbortController().signal + +/** One `#{...}`-joined tmux reading line for the eight queried fields. */ +function tmuxLine(fields: { + sessionName?: string + windowIndex?: string + windowName?: string + paneIndex?: string + paneId?: string + windowActive?: string + paneActive?: string + windowLayout?: string +} = {}): string { + return [ + fields.sessionName ?? '0', + fields.windowIndex ?? '1', + fields.windowName ?? 'node', + fields.paneIndex ?? '2', + fields.paneId ?? '%90', + fields.windowActive ?? '1', + fields.paneActive ?? '0', + fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ].join('\\t') +} + +function runResult(stdout: string, overrides: Partial = {}): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A scriptable fake `ctx.bash` recording the command it was asked to run. */ +class FakeBash extends BashExecutor { + commands: string[] = [] + result: BashRunResult = runResult(`${tmuxLine()}\n`) + runError?: Error + + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? '/work', + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + override async run(spec: BashExecSpec): Promise { + this.commands.push(spec.command) + if (this.runError) throw this.runError + return this.result + } + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }> +async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }> +async function mount( + config: Config = {}, + withBash = false, +): Promise<{ ctx: Context; bash: FakeBash | undefined }> { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let bash: FakeBash | undefined + if (withBash) { + await ctx.plugin(FakeBash) + bash = ctx.bash as FakeBash + } + await ctx.plugin(tmuxContext, config) + return { ctx, bash } +} + +function sessionAgent(session: Session, id = 'agent'): Agent { + return { + id: SessionId(id), + options: {}, + session, + status: 'running', + acceptsNextStep: true, + ctx: new Context(), + followup: () => {}, + steer: () => {}, + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) + }, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function openMessageTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +function contextTexts(session: Session): string[] { + const texts: string[] = [] + for (const event of session.events) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') { + texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + } + return texts +} + +async function fire( + ctx: Context, + agent: Agent, + turn: number, + step: number, + signal: AbortSignal = SIGNAL, +): Promise { + await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('tmux-context injection', () => { + it('injects the tmux location on the first step of a turn', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('first')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toEqual([ + 'tmux location (turn 1):\n' + + 'session 0, window 1 "node", pane 2 %90\n' + + 'window active=1, pane active=0, ' + + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ]) + const event = session.events.at(-1) + if (event?.type !== 'user/message') throw new Error('missing tmux context') + expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) + expect(event.surfaceOp).toBe('append') + }) + + it('queries the pane this process runs in and matches its controlling tty', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('command')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(bash.commands).toHaveLength(1) + const command = bash.commands[0]! + expect(command).toContain('[ -n "$TMUX_PANE" ]') + expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p') + // Guards against an inherited $TMUX_PANE: the pane's tty must equal this + // process's controlling tty (resolved for this exact pid). + expect(command).toContain(`ps -o tty= -p ${process.pid}`) + // The exact fragment matters: unquoted, `#` starts a shell comment and the + // substitution silently breaks while a substring check still passes. + expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1') + expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]') + }) + + it('does not run on later steps of a turn', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('later-step')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 2) + + expect(bash.commands).toHaveLength(0) + expect(contextTexts(session)).toHaveLength(0) + }) + + it('re-injects a new turn only when tmux state changed', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('change')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Same state on turn 2: suppressed. + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(contextTexts(session)).toHaveLength(1) + + // Moved pane on turn 3: re-injected. + bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + + const texts = contextTexts(session) + expect(texts).toHaveLength(2) + expect(texts[1]).toContain('tmux location (turn 3):') + expect(texts[1]).toContain('window 1 "shell", pane 2 %12') + }) + + it('honors a positive refresh interval between injections', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) + const session = new Session(SessionId('interval')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Changed state but inside the interval: suppressed, and never queried. + bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`) + vi.setSystemTime(5_000) + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + expect(contextTexts(session)).toHaveLength(1) + expect(bash.commands).toHaveLength(1) + + // Past the interval: queried and re-injected. + vi.setSystemTime(12_000) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + expect(contextTexts(session)).toHaveLength(2) + expect(bash.commands).toHaveLength(2) + }) +}) + +describe('tmux-context prior-reading resilience', () => { + it('treats a prior non-text plugin reading as absent and injects afresh', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-non-text')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'reasoning', text: 'not a location' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) + + it('treats a prior single-line plugin reading (no newline) as empty state', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-single-line')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'single line, no newline' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + // Empty prior state never equals the multi-line reading, so it re-injects. + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) +}) + +describe('tmux-context no-op paths', () => { + it('is a no-op when no bash executor is mounted', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('no-bash')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('', { exitCode: 1 }) + const session = new Session(SessionId('outside-tmux')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the reading has the wrong field count', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('0\\t1\\tnode\n') + const session = new Session(SessionId('malformed')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the pane id is empty', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) + const session = new Session(SessionId('empty-pane')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('ordering')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + let ordinarySawContext = false + ctx.on('agent/step', (subject) => { + ordinarySawContext = subject.session.events.some( + event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context', + ) + }) + + const abort = new AbortController() + abort.abort() + await fire(ctx, agent, 1, 1, abort.signal) + expect(contextTexts(session)).toHaveLength(0) + + await fire(ctx, agent, 1, 1) + expect(ordinarySawContext).toBe(true) + expect(contextTexts(session)).toHaveLength(1) + }) +}) + +describe('tmux-context configuration', () => { + it('rejects a negative refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) + + it('rejects a non-integer refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) +}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json new file mode 100644 index 0000000000..fe893f9402 --- /dev/null +++ b/packages/context/tmux-context/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..a27c126b75 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -688,6 +688,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionRegistry', + summary: 'Cross-process live-session registry.', + methods: [ + { + signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>', + jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */', + }, + { + signature: 'abstract retitle(sessionId: SessionId, title: string): Promise', + jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */', + }, + { + signature: 'abstract list(): Promise', + jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */', + }, + ], + }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -1543,6 +1561,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashSandboxInfo', declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, + { + name: 'BootId', + declaration: 'export type BootId = Branded<\'BootId\'>;', + }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', @@ -2247,6 +2269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionRegistration', + declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}', + }, + { + name: 'SessionRegistryRecord', + declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 058ebe87af..cbe8c79780 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -39,15 +39,15 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `toolTasks` | owner defaults | Background-task control-tool config, or `false` | | `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `workspaceContext` | required | Workspace-instruction config, or `false` | -| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index | +| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | JSONL persistence root and parent of the derived `session-query.db` index | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | -| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | -| `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. +Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-` and creates it fresh. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit. + +`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default. ## Front door diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 50145e6c29..fc59453f0e 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -30,20 +30,20 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", - "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -53,21 +53,21 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c60ba94b3c..df644090c8 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -30,6 +30,11 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'tui-demo' + +// The bundle's own fallback stays project-local: a plugin must never assume +// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot +// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any +// shared-store policy, and explicit config wins over both. const DEFAULT_PERSISTENCE_ROOT = './.sessions' // Each front door keeps a complete Loader contract so its deployment config is @@ -53,7 +58,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -61,13 +71,6 @@ export interface Config { sessionReferences?: SessionReferenceConfig /** TUI transcript's optional first line; absent renders nothing on start. */ welcome?: string - /** - * Shell command template the TUI prints on exit and lists under `/resume`, - * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -78,8 +81,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -94,33 +95,38 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, dshHome: z.string(), sessionTitle: agentCore.SessionTitleConfigSchema, - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + // No schema default: schemastery would materialize it before composeTuiApp + // runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount. + persistenceRoot: z.string(), persistenceCompression: JsonlCompressionSchema, sessionReferences: SessionReferenceService.Config, welcome: z.string(), - resumeCommand: z.string(), ui: uiTui.TuiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ /** * Compose the spine, TUI, JSONL persistence, and user-question tool around one - * exact fresh or resumed session identity. The TUI subscribes to startup - * failures before the spine creates the agent. + * exact fresh or resumed session identity, taken from the launcher's + * {@link uiTui.MAIN_SESSION_ID_KEY} slot. The TUI subscribes to startup failures + * before the spine creates the agent. * @param ctx - context receiving the app's child plugins. * @param config - validated app configuration. */ export function composeTuiApp(ctx: Context, config: Config): void { - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + // The launcher, not the deployment config, owns `main`'s session identity: it + // reaches a Loader-mounted bundle only through this context slot. A launcher + // that supplies an id knows whether that session already exists, so it also + // states whether to load persisted history. No launcher means mint one here. + const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY) + const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`) const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT + const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { @@ -135,7 +141,6 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, - ...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand }, sessionId, }) ctx.plugin(agentCore, { @@ -146,7 +151,9 @@ export function composeTuiApp(ctx: Context, config: Config): void { provider: config.provider, model: config.model, cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, + // `resumeSessionId` requires existing persisted history and rejects a + // missing log, so only a launcher that asked to resume takes that path. + ...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId }, }], }) ctx.plugin(toolAskUser) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f647b3d9c6..88519e33a1 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -3,6 +3,8 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import { SessionId } from '@deepseek-ai/dsh-session' +import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui' import * as tuiAgent from '../src/index.ts' interface PluginCall { @@ -10,12 +12,21 @@ interface PluginCall { readonly config: unknown } -function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { +/** + * Record the composed plugin tree. `identity` stands in for the launcher-owned + * {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session. + */ +function recordingContext( + identity?: MainSessionIdentity, + sessionsRoot?: string, +): { readonly ctx: Context; readonly calls: PluginCall[] } { const calls: PluginCall[] = [] const ctx = { plugin(plugin: { name?: string }, config?: unknown) { calls.push({ name: plugin.name ?? '', config }) }, + get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity + : key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined, } as unknown as Context return { ctx, calls } } @@ -39,7 +50,6 @@ describe('dsh-tui-demo app', () => { maxReferenceBytes: 1234, }, welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, @@ -71,7 +81,6 @@ describe('dsh-tui-demo app', () => { const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', theme: { color: false }, maxToolOutputLines: 3, }) @@ -100,16 +109,46 @@ describe('dsh-tui-demo app', () => { }) }) - it('resumes the configured session and applies runtime defaults', () => { - const { ctx, calls } = recordingContext() + it('uses the launcher sessions-root slot through schema-normalized config', () => { + // The Loader normalizes config through the schemastery Config BEFORE apply + // runs. A schema .default() on persistenceRoot would materialize here and + // permanently shadow the launcher slot — the regression this test pins. + const normalized = tuiAgent.Config({ + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + } as never) + expect(normalized.persistenceRoot).toBeUndefined() + + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, normalized) + expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' }) + expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') }) + }) + + it('lets an explicit persistenceRoot win over the launcher slot', () => { + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + persistenceRoot: '/explicit/root', + workspaceContext: false, + }) + expect(calls[2]?.config).toEqual({ root: '/explicit/root' }) + }) + + it('loads persisted history for a launcher-selected resume identity', () => { + // The bundle default stays project-local: shared-store policy is the + // launcher's, which patches `persistenceRoot` itself (the dsh CLI does). + const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true }) tuiAgent.composeTuiApp(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: 'persisted-session', workspaceContext: false, }) expect(calls[2]?.config).toEqual({ root: './.sessions' }) + expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') }) expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) @@ -119,12 +158,24 @@ describe('dsh-tui-demo app', () => { }) }) - it('normalizes an empty resume id and routes apply through the same composition', () => { + it('creates a launcher-minted identity fresh rather than loading history', () => { + const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false }) + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + }) + + expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]) + .toMatchObject({ id: 'main', sessionId: 'minted-session' }) + }) + + it('mints a fresh session with no launcher slot and routes apply through the same composition', () => { const { ctx, calls } = recordingContext() tuiAgent.apply(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: '', goals: false, workspaceContext: false, }) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d26d5b7da6..0ddf3b411c 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../util/paths" + }, { "path": "../../session-query/session-query" }, diff --git a/packages/guard/README.md b/packages/guard/README.md index b7375fd2bb..a164bdcddb 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,10 +2,11 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | +| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml new file mode 100644 index 0000000000..801d0979b6 --- /dev/null +++ b/packages/guard/source-guard/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md +README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c +README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md new file mode 100644 index 0000000000..f083ef0af5 --- /dev/null +++ b/packages/guard/source-guard/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +English | [中文](README.zh.md) + +An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting. + +## Config + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back. + +`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. + +## Which paths are protected + +Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `/.worktrees/...`, and are exactly where edits belong. + +Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open. + +Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable. + +A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file. + +The gate is deliberately narrow: + +- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates. +- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell. +- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct. +- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents. +- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation. + +Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed. + +## How the denial lifts + +Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place. + +Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Enforcement point + +The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes. + +## Model Experience + +### Denied filesystem call + +#### What the model sees + +A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin. + +##### Denial result + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token effect + +Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree. +- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side. +- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it. +- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly. +- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over. +- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit. +- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read. diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md new file mode 100644 index 0000000000..7c9efa7b8d --- /dev/null +++ b/packages/guard/source-guard/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +[English](README.md) | 中文 + +这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。 + +## 配置 + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。 + +`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 + +已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 + +## 受保护的路径 + +保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `/.worktrees/...`,而这正是应该进行编辑的位置。 + +解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。 + +要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。 + +`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。 + +门禁刻意保持较窄的范围: + +- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。 +- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。 +- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。 +- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。 +- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。 + +插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。 + +## 如何解除拒绝 + +是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。 + +解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## 强制执行点 + +门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。 + +## 测试 + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。 + +## 模型体验 + +### 被拒绝的文件系统调用 + +#### 模型看到的内容 + +如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。 + +##### 拒绝结果 + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token 影响 + +未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。 + +#### KV Cache 影响 + +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。 +- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。 +- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。 +- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。 +- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。 +- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。 +- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。 diff --git a/packages/guard/source-guard/package.json b/packages/guard/source-guard/package.json new file mode 100644 index 0000000000..4e893e0a69 --- /dev/null +++ b/packages/guard/source-guard/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-source-guard", + "description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/guard/source-guard/src/index.ts b/packages/guard/source-guard/src/index.ts new file mode 100644 index 0000000000..4e242efbd0 --- /dev/null +++ b/packages/guard/source-guard/src/index.ts @@ -0,0 +1,319 @@ +/** + * Denies model-driven file mutation inside a dsh staging worktree until the + * calling session has loaded the required customization skill. Config, git + * resolution, and satisfaction semantics live in the package README; rationale + * lives in the source-guard Agent Note. + * @module @deepseek-ai/dsh-source-guard + */ + +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import z from 'schemastery' +import { canonicalPath } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-fs' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' + +export const name = 'source-guard' + +/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */ +export const inject = ['fs'] + +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} + +export const Config: z = z.object({ + requiredSkill: z.string().default('dsh-customize'), + tools: z.array(z.string()).default(['write', 'edit']), + protectedCheckout: z.string().default(fileURLToPath(import.meta.url)), +}) + +/** + * The tool whose successful call satisfies the guard. Fixed, not configurable: + * this is the harness's own skill-loading tool name, so a deployment that + * renamed it has no skill to load and nothing for this guard to observe. + */ +const SKILL_TOOL = 'skill' + +/** + * The argument key every gated tool names its target with. `write` and `edit` + * share it (`dsh-tool-fs`), and gating a tool that does not is a + * misconfiguration the guard reports rather than silently allowing. + */ +const PATH_ARGUMENT = 'file_path' + +/** + * The absolute `file_path` a gated call targets, or `undefined` when the + * arguments carry no usable one. Arguments arrive as the loop's parsed model + * JSON, so this is a model-input boundary: any shape is possible. + * + * A relative path resolves against the calling session's workspace, exactly as + * the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only + * absolute paths would leave `write` with a relative `file_path` as an + * unguarded path to the same file. + */ +function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined { + if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined + const value = (argumentsValue as Record)[PATH_ARGUMENT] + if (typeof value !== 'string' || value.length === 0) return undefined + if (isAbsolute(value)) return resolve(value) + // Without a session cwd the tools fall back to a provider-owned default this + // guard cannot observe, so the target is genuinely unresolvable here. + return sessionCwd === undefined ? undefined : resolve(sessionCwd, value) +} + +/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */ +interface Worktree { + /** Branch name from `HEAD`, or `undefined` for a detached HEAD. */ + branch: string | undefined + /** + * Symlink-resolved absolute path of the shared git directory, identifying the + * repository across worktrees. Canonical because two paths reaching one + * repository by different symlink routes must compare equal — on macOS a + * session cwd under `/var/...` and a configured path under `/private/var/...` + * name the same directory, and a lexical comparison would fail open. + */ + commonDir: string +} + +/** + * What one git-metadata path holds: a file's text, the fact that it is a + * directory, or nothing resolvable. Every caller treats the unresolvable case + * as "not a worktree" and lets the call proceed, so distinguishing absence + * from a permission error would change no decision. + */ +type GitEntry = + | { kind: 'file'; text: string } + | { kind: 'directory' } + | { kind: 'absent' } + +/** Probe one git-metadata path, reading its text when it is a regular file. */ +async function readGitEntry(ctx: Context, path: string): Promise { + try { + const target = await ctx.fs.resolve(path) + const info = await ctx.fs.stat(target) + if (info?.type === 'directory') return { kind: 'directory' } + if (info?.type !== 'file') return { kind: 'absent' } + return { kind: 'file', text: await ctx.fs.readText(target) } + } catch { + // Any resolve/stat/read failure (absent, denied, unreadable encoding) + // yields no git identity. Nothing else can reach here: the guard performs + // no other IO. + return { kind: 'absent' } + } +} + +/** + * Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a + * detached HEAD holds a raw object id and has no branch, which no staging + * pattern can match. + */ +function branchFromHead(head: string): string | undefined { + const trimmed = head.trim() + const ref = 'ref: refs/heads/' + return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined +} + +/** + * Resolve the git directory a worktree root's `.git` entry designates, plus + * the shared common directory. A plain clone's `.git` is a directory that is + * its own common dir; a linked worktree's `.git` is a file pointing into the + * main repository's `worktrees/`, whose common dir is two levels up. + * A `gitdir:` pointer may be relative, which git resolves against the worktree + * directory holding it. + */ +async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> { + const dotGit = resolve(root, '.git') + const entry = await readGitEntry(ctx, dotGit) + // A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the + // common dir; a linked worktree keeps a `.git` FILE pointing elsewhere. + if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) } + if (entry.kind === 'absent') return undefined + const prefix = 'gitdir:' + const trimmed = entry.text.trim() + if (!trimmed.startsWith(prefix)) return undefined + const pointer = trimmed.slice(prefix.length).trim() + if (pointer.length === 0) return undefined + const gitDir = resolve(root, pointer) + // `/worktrees/` — the shared repository is two levels up. + return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) } +} + +/** + * Walk from a path toward the filesystem root and resolve the first enclosing + * worktree, or `undefined` when the path is inside none. + */ +async function findWorktree(ctx: Context, from: string): Promise { + let current = from + for (;;) { + const dirs = await resolveGitDir(ctx, current) + if (dirs !== undefined) { + const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD')) + return { + branch: head.kind === 'file' ? branchFromHead(head.text) : undefined, + commonDir: dirs.commonDir, + } + } + const parent = dirname(current) + if (parent === current) return undefined + current = parent + } +} + +/** + * The skill name a `skill` call's raw argument JSON requested, or `undefined` + * when the JSON is malformed or carries no string `name`. The log stores the + * model's unparsed argument string, so this is a model-JSON boundary. + */ +function skillNameOf(rawArguments: string): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(rawArguments) + } catch { + // The model produced argument text that is not JSON; the call cannot have + // named a skill. Nothing else in this try can throw. + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const value = (parsed as Record).name + return typeof value === 'string' ? value : undefined +} + +/** + * Whether the session's durable log records a successful load of + * `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so + * satisfaction survives a session resume: the log is the only state. + */ +function skillLoaded(session: Session, requiredSkill: string): boolean { + const requested = new Map() + for (const event of session.events) { + if (event.type === 'tool/call') { + if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments) + continue + } + const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined + if (block === undefined || block.isError === true) continue + const rawArguments = requested.get(block.toolCallId) + if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true + } + return false +} + +/** The denial text a blocked call reports to the model. */ +function denialReason(path: string, branch: string, requiredSkill: string): string { + return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. ` + + `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.` +} + +/** + * Install the guard's listener. + * @param ctx - plugin context; the listener is scoped to it and disposed with it. + * @param config - validated {@link Config}; re-checked fail-loud here. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery's .default() guarantees the fields are set after validation. + const requiredSkill = config.requiredSkill as string + const tools = config.tools as string[] + if (tools.length === 0) { + throw new Error('source-guard: `tools` must not be empty') + } + if (requiredSkill.trim().length === 0) { + throw new Error('source-guard: `requiredSkill` must not be blank') + } + const gated = new Set(tools) + + const protectedCheckout = config.protectedCheckout as string + if (!isAbsolute(protectedCheckout)) { + throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`) + } + // Resolved once per plugin lifetime: the worktree this guard arms for, which + // supplies both the protected repository and the protected branch. A harness + // running from an installed copy resolves a different repository (or none) + // and therefore guards nothing, which is correct — the rule is meaningless + // outside a source checkout. + let protectedRepository: Promise | undefined + + /** The repository containing {@link Config.protectedCheckout}. */ + function repository(): Promise { + protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout)) + return protectedRepository + } + + // Worktree identity per directory, cached for the plugin's lifetime: a + // directory's repository and branch are stable in practice, and re-reading + // git metadata on every write would repeat identical IO. A mid-session + // branch switch is therefore not observed (see the README). + const worktrees = new Map>() + + /** Resolve (and memoize) the worktree enclosing a target path's directory. */ + function worktreeOf(path: string): Promise { + const directory = dirname(path) + let pending = worktrees.get(directory) + if (pending === undefined) { + pending = findWorktree(ctx, directory) + worktrees.set(directory, pending) + } + return pending + } + + /** + * The target path and the staging branch protecting it, or `undefined` when + * the call may proceed. Fails open on every unresolvable case: a path outside + * any worktree, a detached HEAD, a different repository, or unreadable git + * metadata leaves the call to the rest of the chain, because a guard that + * blocked writes whenever git identity was unavailable would be worse than + * the violation it prevents. + */ + async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> { + if (!gated.has(exec.name)) return undefined + const path = targetPath(exec.arguments, session.header.cwd) + if (path === undefined) return undefined + const launcher = await repository() + // A detached launcher checkout names no branch to protect, so nothing is. + if (launcher?.branch === undefined) return undefined + // Resolution walks OUTWARD from the target, so it reports the INNERMOST + // enclosing worktree: a task worktree nested under the protected tree + // answers with its own task branch, which is not the launcher's. That is + // what keeps the prescribed workflow unblocked. + const worktree = await worktreeOf(path) + if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined + // Only the branch the launcher itself runs from is protected: a stale + // sibling checkout of the same repository is not the live deployment. + if (worktree.branch !== launcher.branch) return undefined + return { path, branch: launcher.branch } + } + + ctx.on('tools/pre-execute', async (exec, next): Promise => { + // A direct `ctx.tools.execute()` caller has no session to replay and no + // model to correct; only agent-loop calls are gated. + if (exec.agent === undefined) return next() + const { session } = exec.agent + const target = await protectedTarget(exec, session) + if (target === undefined) return next() + if (skillLoaded(session, requiredSkill)) return next() + return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) } + }) +} diff --git a/packages/guard/source-guard/src/invariant.ts b/packages/guard/source-guard/src/invariant.ts new file mode 100644 index 0000000000..f5b68cf24d --- /dev/null +++ b/packages/guard/source-guard/src/invariant.ts @@ -0,0 +1,85 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`. + * @module @deepseek-ai/dsh-source-guard/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard' + +/** Cordis companion plugin name. */ +export const name = 'source-guard-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * The durable shape of this guard's refusal. The denial is the package's only + * model-visible output, and it is actionable only when it names all three of + * the offending path, the branch that protects it, and the skill that lifts + * the denial — a refusal missing any of them tells the model to stop without + * telling it how to proceed. + */ +const DENIAL = new RegExp( + '^Error: Editing "(?.+)" directly is not allowed: ' + + 'it is inside the dsh checkout this session is running from, on branch (?\\S+)\\. ' + + 'Load the (?\\S+) skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock\\.$', +) + +/** The denial prefix identifying a result this package produced, before its full shape is validated. */ +const DENIAL_PREFIX = 'Error: Editing "' + +/** Validate one guard-produced denial result's model-facing text. */ +function validateDenial(text: string, fail: InvariantFailure): void { + const match = DENIAL.exec(text) + if (match === null) { + fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it') + } + // The pattern's `\S+` groups already establish a non-empty branch and skill; + // only path absoluteness remains to check. + const { path } = match.groups as { path: string } + if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { + fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`) + } +} + +/** Validate every guard denial carried by one session's durable log. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const event of session.events) { + if (event.type !== 'tool/result') continue + validateEvent(event, fail) + } +} + +/** Validate one durable tool result, when it carries this package's denial. */ +function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void { + const result = event.data.message.content[0] + if (result.isError !== true) return + for (const block of result.content) { + if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue + validateDenial(block.text, fail) + } +} + +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Install validation for loaded and newly appended denial results. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [, event] = args as [Session, SessionEvent] + if (event.type !== 'tool/result') return + validateEvent(event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/guard/source-guard/tests/invariant.spec.ts b/packages/guard/source-guard/tests/invariant.spec.ts new file mode 100644 index 0000000000..cd51c1a9a1 --- /dev/null +++ b/packages/guard/source-guard/tests/invariant.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant' + +/** + * The companion validates the durable shape of this package's only + * model-visible output: its refusal must name the offending path, the branch + * that protects it, and the skill that lifts it, so the model can act on the + * denial instead of merely stopping. + */ + +const PATH = '/repo/staging/file.ts' + +/** A well-formed denial for `path`, as the guard materializes it into a tool result. */ +function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string { + return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + `on branch ${branch}. Load the ${skill} skill first and follow it ` + + '— implement in a task worktree, then integrate under the staging lock.' +} + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(SourceGuardInvariant) + return ctx +} + +/** One durable tool result carrying `content`, error-flagged unless told otherwise. */ +function result(content: unknown[], isError = true): SessionEvent { + return { + type: 'tool/result', + seq: 0, + time: 1, + surfaceOp: 'append', + sourceEventSeqs: [0], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: content as ContentBlock[], + isError, + }), + }, + } +} + +describe('source-guard invariants', () => { + it('accepts a denial naming the path, branch, and skill', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow() + }) + + it('accepts a Windows-style absolute path', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept-windows')) + const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }]) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a successful result that merely quotes the prefix', false], + ])('ignores %s', async (_label, isError) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-success')) + const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]], + ['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]], + ])('ignores %s', async (_label, content) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-other')) + expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow() + }) + + it('ignores an event that is not a tool result', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-kind')) + const event: SessionEvent = { + type: 'user/message', + seq: 0, + time: 1, + surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }), + } + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + [ + 'omits the skill that lifts it', + `Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`, + ], + [ + 'names a relative path', + denial('relative/file.ts'), + ], + ])('rejects a denial that %s', async (_label, text) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('reject')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/) + }) + + it('rejects an invalid denial already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('late')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}', + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: [{ type: 'text', text: denial('relative/file.ts') }], + isError: true, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/) + }) +}) diff --git a/packages/guard/source-guard/tests/loader-composition.e2e.ts b/packages/guard/source-guard/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2595dc8d2a --- /dev/null +++ b/packages/guard/source-guard/tests/loader-composition.e2e.ts @@ -0,0 +1,93 @@ +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// The Loader config lives under examples so both launch modes exercise the same +// deployable topology: a local fixture adapter plus bare workspace plugins. +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', + import.meta.url, +)) +const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** Every `.jsonl` session log under `dir`. */ +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +/** + * Write git metadata mirroring the installer layout — a master clone owning the + * shared git directory and one linked worktree on a staging branch — and return + * the worktree file the model will try to write. + */ +async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { + const gitDir = join(cwd, 'master', '.git') + const worktreeGitDir = join(gitDir, 'worktrees', 'staging') + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') + const checkout = join(cwd, 'staging') + await mkdir(checkout, { recursive: true }) + await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) + const target = join(checkout, 'guarded.ts') + await writeFile(target, 'original\n') + return { checkout, target } +} + +describe('source-guard through a real headless cordis.yml', () => { + it('denies the model-requested write and leaves the staged file untouched', async () => { + let events: SessionEvent[] = [] + let contents = '' + let target = '' + const { stderr } = await runLoaderSmoke({ + label: 'source-guard headless smoke', + tempDirPrefix: 'source-guard-e2e-', + binScript, + configPath, + tsconfigPath: repoTsconfig, + binArgs: ['--config', configPath, 'edit the guarded file'], + // The isolated cwd is not known when these options are built, so the + // config and adapter resolve their fixture paths against the child's own + // cwd, which is that directory. + prepare: async (cwd) => { + // macOS puts the temp directory behind the /var -> /private/var + // symlink; the child resolves its cwd, so compare against the same + // real path rather than the symlinked one this process was handed. + target = (await stagingFixture(await realpath(cwd))).target + }, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + contents = await readFile(target, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const results = events.filter( + (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + expect(results).toHaveLength(1) + const result = results[0]?.data.message.content[0] + expect(result?.isError).toBe(true) + const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') + expect(text).toBe( + `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + // Enforcement, not advice: the guard denies before dispatch, so the file + // the model targeted still holds its original bytes. + expect(contents).toBe('original\n') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/guard/source-guard/tests/source-guard.spec.ts b/packages/guard/source-guard/tests/source-guard.spec.ts new file mode 100644 index 0000000000..ccbc7d7c2b --- /dev/null +++ b/packages/guard/source-guard/tests/source-guard.spec.ts @@ -0,0 +1,581 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' +import type { Config } from '@deepseek-ai/dsh-source-guard' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** + * Behavior suite for the staging-source guard: worktree resolution over REAL + * git metadata fixtures (a staging worktree, a nested task worktree, a plain + * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed + * from the durable session log, and fail-loud config validation — all driven + * through a real agent loop against a scripted mock adapter (no network). + */ + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** + * Build a git-metadata fixture tree that mirrors the real installer layout: a + * `master` clone holding the shared git directory, linked worktrees registered + * under `master/.git/worktrees/`, and one file per worktree to target. + */ +async function fixture(): Promise<{ + /** Absolute path of the fixture container. */ + root: string + /** A file inside the staging worktree — the protected target. */ + stagingFile: string + /** A file inside a task worktree NESTED under the staging tree. */ + taskFile: string + /** A file inside a SIBLING staging worktree of the same repository, on another branch. */ + siblingFile: string + /** A file inside the plain master clone. */ + masterFile: string + /** A file inside a worktree whose HEAD is detached. */ + detachedFile: string + /** A file inside an unrelated repository sharing no git directory. */ + outsideFile: string + /** A file under no repository at all. */ + looseFile: string +}> { + const root = await mkdtemp(join(tmpdir(), 'source-guard-')) + roots.push(root) + const master = join(root, 'master') + const gitDir = join(master, '.git') + await mkdir(join(gitDir, 'worktrees'), { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(master, 'file.ts'), 'master\n') + + /** Register one linked worktree at `path` whose HEAD file holds `head`. */ + async function linked(path: string, name: string, head: string): Promise { + const worktreeGitDir = join(gitDir, 'worktrees', name) + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(worktreeGitDir, 'HEAD'), head) + await mkdir(path, { recursive: true }) + await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`) + const file = join(path, 'file.ts') + await writeFile(file, 'content\n') + return file + } + + const staging = join(root, 'staging-20260728T022827Z') + const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + // The prescribed workflow's task worktree lives INSIDE the staging tree. + const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n') + // A stale staging worktree from an earlier install: same repository, different branch. + const siblingFile = await linked( + join(root, 'staging-20260727T045831Z'), + 'staging-20260727T045831Z', + 'ref: refs/heads/dsh-staging/20260727T045831Z\n', + ) + const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n') + + const outside = join(root, 'outside') + await mkdir(join(outside, '.git'), { recursive: true }) + await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + const outsideFile = join(outside, 'file.ts') + await writeFile(outsideFile, 'outside\n') + + const loose = join(root, 'loose') + await mkdir(loose, { recursive: true }) + const looseFile = join(loose, 'file.ts') + await writeFile(looseFile, 'loose\n') + + return { + root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile, + } +} + +/** + * Boot the core spine, a real local filesystem, and the guard, pointing + * `protectedCheckout` at a fixture path so the guard arms for the fixture + * repository instead of the checkout these tests actually run in. + */ +async function harness(protectedCheckout: string, config: Partial = {}): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SourceGuard, { ...config, protectedCheckout }) + for (const name of ['write', 'edit', 'read', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +/** Every tool result in the agent's log as `{ isError, text }`, in log order. */ +function results(agent: Agent): { isError: boolean; text: string }[] { + return [...agent.session.events] + .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + .map(event => event.data.message.content[0]) + .map(result => ({ + isError: result.isError === true, + text: result.content.map(block => block.type === 'text' ? block.text : '').join(''), + })) +} + +/** + * Durable events recording completed `skill` calls, as a RESUMED session's seed: + * the guard's satisfaction check then has nothing but the log to read, with no + * in-memory state from an original run to fall back on. + */ +function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + ] + for (const [index, call] of calls.entries()) { + const callId = CallId(`prior${index}`) + const seq = events.length + events.push({ + type: 'tool/call', + seq, + time: seq + 1, + data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments }, + }) + events.push({ + type: 'tool/result', + seq: seq + 1, + time: seq + 2, + surfaceOp: 'append', + sourceEventSeqs: [seq], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId, + content: [{ type: 'text', text: 'loaded' }], + isError: call.isError ?? false, + }), + }, + }) + } + const tail = events.length + events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } }) + events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } }) + return events +} + +/** Resume a session from durable seed events and let the model attempt one write at `path`. */ +async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise { + const adapter = new MockAdapter([ + toolCallResponse(CallId('c0'), 'write', { file_path: path }), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const { agent } = await ctx.agentLoop.createAgent(ctx, { + sessionId: SessionId(id), + seed, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */ +async function run( + ctx: Context, + calls: { name: string; args: Record }[], + cwd?: string, +): Promise { + const adapter = new MockAdapter([ + ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create( + SessionId('s1'), + { provider: 'mock', model: 'mock' }, + cwd === undefined ? {} : { cwd }, + ) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +describe('staging protection', () => { + it('denies a write inside the staging worktree and names the path, branch, and skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + const [result] = results(agent) + expect(result?.isError).toBe(true) + expect(result?.text).toBe( + `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + }) + + it('denies an edit inside the staging worktree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }]) + expect(results(agent)[0]?.isError).toBe(true) + }) + + it('allows a read inside the staging worktree, since inspection never violates the skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write inside a task worktree nested under the staging tree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write in the plain clone that owns the shared git directory', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write on a staging-named branch in an unrelated repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under a detached HEAD, which names no branch to match', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write when the git metadata exists but cannot be read', async () => { + const paths = await fixture() + // A `.git` pointer that stats as a file yet fails to read leaves the guard + // with no branch to judge; failing open beats blocking every edit. + const unreadable = join(paths.root, 'unreadable') + await mkdir(unreadable, { recursive: true }) + await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`) + await chmod(join(unreadable, '.git'), 0o000) + const file = join(unreadable, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under no repository at all', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when its own location is inside no repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.looseFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when the launcher checkout has a detached HEAD', async () => { + const paths = await fixture() + // A detached launcher names no branch, so there is no branch to protect. + const ctx = await harness(paths.detachedFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies when the target and the protected checkout reach one repository through different symlinks', async () => { + const paths = await fixture() + // macOS reaches the temp directory through both `/var/...` and + // `/private/var/...`; a lexical repository comparison would treat the two + // routes as different repositories and fail open on every write. + const link = join(paths.root, 'link') + await symlink(dirname(paths.stagingFile), link, 'dir') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('denies a RELATIVE target path resolved against the session workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + // The filesystem tools resolve a relative `file_path` against the session + // cwd, so judging only absolute paths would leave this as an unguarded + // route to the same file. + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile)) + expect(results(agent)[0]?.text).toContain('directly is not allowed') + }) + + it('ignores a relative target path when the session names no workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('ignores a call whose target path is an empty string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it.each([ + ['a non-string file_path', { file_path: 7 }], + ['no file_path at all', { other: 'x' }], + ])('ignores a gated call carrying %s', async (_label, args) => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args }]) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const callId = CallId('raw') + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ], + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it.each([ + ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'], + ['an empty `.git` pointer', 'gitdir:\n'], + ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'], + ])('allows a write behind %s', async (_label, pointer) => { + const paths = await fixture() + const broken = join(paths.root, 'broken') + await mkdir(broken, { recursive: true }) + await writeFile(join(broken, '.git'), pointer) + const file = join(broken, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => { + const paths = await fixture() + // `git worktree add` writes an absolute pointer, but a relocated or + // hand-written one may be relative; git accepts both, so the guard must + // resolve both or it would fail open on a real repository layout. + const relative = join(paths.root, 'relative-pointer') + await mkdir(relative, { recursive: true }) + await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n') + const file = join(relative, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('allows a write when the worktree resolves but its HEAD is missing', async () => { + const paths = await fixture() + const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless') + await mkdir(gitDir, { recursive: true }) + const headless = join(paths.root, 'headless') + await mkdir(headless, { recursive: true }) + await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`) + const file = join(headless, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('reuses one resolution for sibling targets in the same directory', async () => { + const paths = await fixture() + const sibling = join(dirname(paths.stagingFile), 'other.ts') + await writeFile(sibling, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'write', args: { file_path: sibling } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([true, true]) + }) + + it('protects whichever branch the launcher checkout is on, whatever its name', async () => { + const paths = await fixture() + // The protected branch is read from `protectedCheckout`'s own worktree, so + // a checkout on an unconventional branch name is still protected — a + // hardcoded name pattern would have silently guarded nothing. + const ctx = await harness(paths.taskFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)[0]?.text).toContain('on branch task/x') + }) + + it('allows a write in a SIBLING checkout of the same repository on another branch', async () => { + const paths = await fixture() + // A stale staging worktree left by an earlier install shares the + // repository but is not the live deployment, so the workflow rule the + // guard enforces does not apply to it. + const ctx = await harness(paths.siblingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('gates only the configured tools', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { tools: ['edit'] }) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'edit', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) +}) + +describe('skill satisfaction', () => { + it('allows the write after a successful load of the required skill in the same turn', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-customize' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent)).toEqual([ + { isError: false, text: 'ok' }, + { isError: false, text: 'ok' }, + ]) + }) + + it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }]) + const agent = await resume(ctx, 'resumed', seed, paths.stagingFile) + expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' }) + }) + + it('does not accept a failed skill load', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }]) + const agent = await resume(ctx, 'failed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('does not accept a different skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-upgrade' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) + + it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([ + { arguments: 'not json' }, + { arguments: '[]' }, + { arguments: '{"name":7}' }, + { arguments: 'null' }, + ]) + const agent = await resume(ctx, 'malformed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('honours a configured skill name other than the default', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' }) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'other-skill' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, false]) + }) +}) + +describe('non-agent callers', () => { + it('leaves a direct registry call ungated, having no session to replay', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const result = await ctx.tools.execute({ + callId: CallId('direct'), + name: 'write', + arguments: { file_path: paths.stagingFile }, + signal: new AbortController().signal, + }) + expect(result.isError).toBe(false) + }) +}) + +describe('config validation', () => { + it.each([ + ['tools', { tools: [] }, '`tools` must not be empty'], + ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'], + ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'], + ])('rejects an invalid %s at load', async (_field, config, message) => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message) + }) +}) + +describe('disposal', () => { + it('stops gating once the plugin fiber is disposed', async () => { + const paths = await fixture() + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile }) + for (const name of ['write', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + await fiber.dispose() + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) +}) diff --git a/packages/guard/source-guard/tsconfig.json b/packages/guard/source-guard/tsconfig.json new file mode 100644 index 0000000000..133b7fefdf --- /dev/null +++ b/packages/guard/source-guard/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..6eb22cf334 --- /dev/null +++ b/packages/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/README.md +README.md: c79caccd05d6cbdda0663dd897490fb6004b8250 +README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md new file mode 100644 index 0000000000..c79caccd05 --- /dev/null +++ b/packages/session-registry/README.md @@ -0,0 +1,15 @@ +# session-registry/ — live-session registry family + +English | [中文](README.zh.md) + +Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer. + +| Package | Role | ctx key | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — | +| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — | + +The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists. diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md new file mode 100644 index 0000000000..c3fff3bc6b --- /dev/null +++ b/packages/session-registry/README.zh.md @@ -0,0 +1,15 @@ +# session-registry/:活跃会话注册表家族 + +[English](README.md) | 中文 + +当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — | +| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — | + +这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。 + +这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。 diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml new file mode 100644 index 0000000000..d08b25d86f --- /dev/null +++ b/packages/session-registry/session-registry-file/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md +README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73 +README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827 diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md new file mode 100644 index 0000000000..b6f29a459e --- /dev/null +++ b/packages/session-registry/session-registry-file/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +English | [中文](README.zh.md) + +File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`). + +## Liveness and crash safety + +Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation. + +## Concurrency + +Both layers are required and neither substitutes for the other. + +- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium. +- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing. + +Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file. + +## Durable format + +`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it. + +## Config + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand | +| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed | +| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud | + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it. diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md new file mode 100644 index 0000000000..35861466ed --- /dev/null +++ b/packages/session-registry/session-registry-file/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +[English](README.md) | 中文 + +[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。 + +## 存活状态与崩溃安全 + +存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。 + +## 并发 + +两层机制都是必需的,任何一层都无法替代另一层。 + +- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。 +- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。 + +写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。 + +## 持久化格式 + +`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。 + +## 配置 + +| 键 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 | +| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 | +| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 | + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。 diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json new file mode 100644 index 0000000000..321a59902d --- /dev/null +++ b/packages/session-registry/session-registry-file/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-file", + "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + } + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "proper-lockfile": "^4.1.2", + "schemastery": "^3.15.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@types/proper-lockfile": "^4.1.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts new file mode 100644 index 0000000000..0c9a5775fb --- /dev/null +++ b/packages/session-registry/session-registry-file/src/file.ts @@ -0,0 +1,97 @@ +/** + * Registry file format: the durable boundary between independent `dsh` + * processes. Every field is validated on read because the medium is shared, + * user-visible, and writable by other harness versions — a foreign or truncated + * file must not crash `dsh list-sessions` into an empty listing that hides live sessions. + * @module @deepseek-ai/dsh-session-registry-file/file + */ + +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' + +/** + * On-disk format version. Pinned at `0` under the pre-release stance: a + * differing version is rejected rather than migrated, matching every other + * harness backend. + */ +export const SESSION_REGISTRY_FORMAT_VERSION = 0 + +/** The complete registry file: a version stamp plus the live records. */ +export interface RegistryFileContents { + /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */ + readonly version: number + /** One record per registered process, in no significant order. */ + readonly records: readonly SessionRegistryRecord[] +} + +/** An empty registry: the value a missing file reads as. */ +export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] } + +/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */ +function parseRecord(value: unknown): SessionRegistryRecord | undefined { + if (typeof value !== 'object' || value === null) return undefined + const row = value as Record + const { sessionId, pid, cwd, startedAt, bootId } = row + if (typeof sessionId !== 'string' || sessionId === '') return undefined + // A non-integer or non-positive pid cannot be probed for liveness. + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined + if (typeof cwd !== 'string' || cwd === '') return undefined + if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined + if (typeof bootId !== 'string' || bootId === '') return undefined + // An absent title is legal (a fresh session has none); a present but + // non-string one is a damaged row rather than a missing optional field. + const { title } = row + if (title !== undefined && typeof title !== 'string') return undefined + return { + sessionId: SessionId(sessionId), + pid, + cwd, + startedAt, + bootId: BootId(bootId), + ...title !== undefined && { title }, + } +} + +/** + * Parse registry file text into records, dropping individually unusable rows. + * + * A row that cannot be interpreted is dropped rather than rejected wholesale: + * one malformed record written by a different harness version must not hide + * every other live session. Unparsable text and a version mismatch yield an + * empty registry for the same reason — the caller republishes the whole file, so + * the next write heals the medium. + * @param text - the raw file contents. + * @returns the records that parsed, and whether the text was fully understood. + */ +export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Swallows only SyntaxError from this one JSON.parse: a torn or foreign + // file heals on the next write, and nothing else can reach this catch. + return { records: [], intact: false } + } + if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false } + const file = parsed as Record + if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false } + if (!Array.isArray(file.records)) return { records: [], intact: false } + const records: SessionRegistryRecord[] = [] + let intact = true + for (const row of file.records) { + const record = parseRecord(row) + if (record === undefined) intact = false + else records.push(record) + } + return { records, intact } +} + +/** + * Serialize records as registry file text. + * @param records - the live records to publish. + * @returns pretty-printed JSON with a trailing newline, for a legible medium. + */ +export function serializeRegistry(records: readonly SessionRegistryRecord[]): string { + const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records } + return `${JSON.stringify(file, undefined, 2)}\n` +} diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts new file mode 100644 index 0000000000..41e080b79a --- /dev/null +++ b/packages/session-registry/session-registry-file/src/index.ts @@ -0,0 +1,233 @@ +/** + * File-backed live-session registry: one lock-guarded JSON file under the + * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every + * operation is a read-modify-write under an advisory lock, because concurrent + * launchers write the same file — the storage-hub JSON backend documents + * last-write-wins for exactly this case and cannot be reused. Liveness is + * derived at read time from the recorded pid. + * @module @deepseek-ai/dsh-session-registry-file + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Context } from 'cordis' +import lockfile from 'proper-lockfile' +import z from 'schemastery' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionRegistry, BootId, + type SessionRegistration, type SessionRegistryRecord, +} from '@deepseek-ai/dsh-session-registry' +import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts' +import { isPidAlive } from './liveness.ts' + +export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts' +export type { RegistryFileContents } from './file.ts' +export { isPidAlive } from './liveness.ts' + +/** The file name holding the registry, relative to {@link Config.root}. */ +export const REGISTRY_FILE_NAME = 'sessions.json' + +/** Default lock staleness threshold; a held lock older than this is reclaimed. */ +const DEFAULT_LOCK_STALE_MS = 10_000 + +/** Default retry budget for a contended lock acquisition. */ +const DEFAULT_LOCK_RETRIES = 10 + +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} + +/** The file-backed {@link SessionRegistry} implementation. */ +export class SessionRegistryFile extends SessionRegistry { + static Config: z = z.object({ + root: z.string().required(), + lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS), + lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES), + }) + + /** Absolute path of the registry file this service reads and writes. */ + readonly file: string + + /** Directory holding {@link file}, created `0o700` on demand. */ + private readonly root: string + + /** Tail of the in-process serialization chain; see {@link mutate}. */ + private chain: Promise = Promise.resolve() + + /** Resolved lock staleness threshold in milliseconds, fixed at construction. */ + private readonly stale: number + + /** Resolved contended-acquisition retry budget, fixed at construction. */ + private readonly retries: number + + constructor(ctx: Context, config: Config) { + super(ctx, BootId(randomUUID())) + this.root = config.root + this.file = join(this.root, REGISTRY_FILE_NAME) + // Resolve the optional tunables here, once: `static Config` supplies these + // same defaults for a Loader mount, and a direct programmatic mount that + // omits them gets them too rather than an undefined lock option. + this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS + this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES + } + + /** @inheritdoc */ + async register(registration: SessionRegistration): Promise<() => Promise> { + const record: SessionRegistryRecord = { + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + ...registration.title !== undefined && { title: registration.title }, + } + await this.mutate(records => [ + ...records.filter(other => other.sessionId !== record.sessionId), + record, + ]) + // The disposer is awaited by Cordis teardown, so the record is durably gone + // before disposal completes rather than racing process exit. A failure here + // is reported, not thrown: the record is already pid-prunable, and an + // unwinding teardown must not be turned into a rejection. + return this.ctx.effect(() => async () => { + try { + await this.mutate(records => records.filter(other => !this.isSelf(other, record))) + } catch (error) { + this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error)) + } + }) + } + + /** @inheritdoc */ + async retitle(sessionId: SessionId, title: string): Promise { + await this.mutate(records => records.map(record => + record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId + ? { ...record, title } + : record)) + } + + /** @inheritdoc */ + async list(): Promise { + // Pruning is a write, so the read path takes the same lock: a listing that + // observed a half-written file could omit a live session. + return this.mutate(records => [...records]) + } + + /** True when a stored record is this exact registration (pid AND incarnation). */ + private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean { + return candidate.sessionId === self.sessionId + && candidate.pid === self.pid + && candidate.bootId === self.bootId + } + + /** + * Serialize one read-modify-write cycle against every other cycle in THIS + * process, then run it under the cross-process lock. + * + * Both layers are required and neither substitutes for the other. The advisory + * lock excludes other processes but is tracked per process, so it rejects a + * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing + * — and a composition that creates several sessions at once really does + * overlap these calls. This chain gives those callers a queue; the lock gives + * independent processes exclusion. + */ + private mutate( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + // Failures must not poison the chain for later callers, so the tail only + // tracks settlement, never the rejection itself. + const result = this.chain.then(() => this.mutateExclusively(change)) + this.chain = result.then(() => undefined, () => undefined) + return result + } + + /** + * Run one locked read-modify-write cycle: read, prune dead records, apply + * `change`, and republish when the result differs from what was stored. + */ + private async mutateExclusively( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + // proper-lockfile needs the target to exist before it can guard it; an + // exclusive create loses harmlessly to a concurrent launcher doing the same. + await this.ensureFile() + const release = await lockfile.lock(this.file, { + stale: this.stale, + retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 }, + }) + try { + const before = await this.read() + const live = before.records.filter(record => isPidAlive(record.pid)) + const next = change(live) + // Republish when a record changed or the medium itself was damaged, so a + // foreign or torn file heals instead of being re-parsed on every read. + if (!before.intact || !sameRecords(before.records, next)) await this.write(next) + return next + } finally { + await release() + } + } + + /** Create the registry file if absent, without disturbing existing content. */ + private async ensureFile(): Promise { + try { + const handle = await open(this.file, 'wx', 0o600) + try { + await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records)) + } finally { + await handle.close() + } + } catch (error) { + // Swallows only EEXIST: another launcher created the file first, which is + // the intended outcome. Every other errno propagates. + /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + + /** Read and parse the registry file; a missing file reads as empty. */ + private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> { + // The caller holds the lock, and acquiring it requires the file to exist, so + // a read failure here is real corruption rather than an absent registry and + // propagates: a swallowed error would report "no live sessions" for a medium + // that could not be read. + return parseRegistry(await readFile(this.file, 'utf8')) + } + + /** Publish the complete record set via temp-write plus atomic rename. */ + private async write(records: readonly SessionRegistryRecord[]): Promise { + const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`) + await writeFile(temp, serializeRegistry(records), { mode: 0o600 }) + await rename(temp, this.file) + } +} + +/** Compare record lists by identity fields, to decide whether a write is needed. */ +function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean { + if (left.length !== right.length) return false + return left.every((record, index) => { + const other = right[index] + return other !== undefined + && record.sessionId === other.sessionId + && record.pid === other.pid + && record.bootId === other.bootId + && record.cwd === other.cwd + && record.startedAt === other.startedAt + && record.title === other.title + }) +} + +export default SessionRegistryFile diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts new file mode 100644 index 0000000000..9fd175abc4 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`. + * @module @deepseek-ai/dsh-session-registry-file/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-file-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the relations a reader must trust (unique live session + * ids, attributable pids) are contract-level and validated by the seam's + * companion around the authoritative `list()`, whatever backend serves it. The + * file medium's own correctness — locking, atomic republication, and + * foreign-row rejection — requires cross-process round-trip tests, not a + * continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts new file mode 100644 index 0000000000..ff7421edd9 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/liveness.ts @@ -0,0 +1,30 @@ +/** + * Process-liveness probe for stored registry records. + * @module @deepseek-ai/dsh-session-registry-file/liveness + */ + +/** + * Signal-0 probe: report whether a pid currently exists. + * + * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such + * process. `EPERM` means the process exists but is owned by another user, which + * is still alive — reporting it dead would drop a live record. Any other errno + * is unexpected and propagates rather than being read as a liveness answer. + * @param pid - the operating-system process id to probe. + * @param kill - signal sender, defaulting to `process.kill`; injected by tests. + * @returns whether a process with this pid exists. + */ +export function isPidAlive( + pid: number, + kill: (pid: number, signal: number) => void = process.kill.bind(process), +): boolean { + try { + kill(pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + if (code === 'EPERM') return true + throw error + } +} diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts new file mode 100644 index 0000000000..56e05d11fd --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts @@ -0,0 +1,27 @@ +/** + * Concurrency-test driver: register one session in a real separate process, + * report readiness on stdout, then stay alive until the parent closes stdin. + * + * Staying alive is load-bearing. The registry prunes records whose process is + * gone, so a driver that exited after writing would be pruned by the next + * writer — the test would then measure pruning instead of the concurrent + * read-modify-write it exists to cover. Argv: ` `. + */ + +import { Context } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' + +const [root, sessionId] = process.argv.slice(2) +if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ') + +const ctx = new Context() +await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 }) +await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() }) +process.stdout.write('registered\n') + +// Hold the process open so its record stays live; the parent ends the run by +// closing stdin, and never disposes the fiber, so no deregistration races the +// parent's read. +process.stdin.resume() +process.stdin.on('end', () => { process.exit(0) }) diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts new file mode 100644 index 0000000000..e48bd46385 --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts @@ -0,0 +1,382 @@ +/** + * Tests for the cross-process live-session registry: records survive a + * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign + * record, the file format rejects foreign and torn media without hiding live + * sessions, disposal deregisters, and concurrent registrations from independent + * processes all survive (the failure the advisory lock exists to prevent). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { execFile, spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile, { + REGISTRY_FILE_NAME, + SESSION_REGISTRY_FORMAT_VERSION, + isPidAlive, + parseRegistry, + serializeRegistry, +} from '@deepseek-ai/dsh-session-registry-file' + +const run = promisify(execFile) + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** Mount the service on a fresh Cordis fiber, returning it with its context. */ +async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root }) + return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile } +} + +const file = (): string => join(root, REGISTRY_FILE_NAME) + +describe('config resolution', () => { + it('applies the shipped lock defaults when a caller omits them', async () => { + // `ctx.plugin` runs the schema, which fills these in, so the constructor's + // own resolution is reachable only by constructing the service directly — + // the path a programmatic embedder takes. + const ctx = new Context() + const service = new SessionRegistryFile(ctx, { root }) + await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' }) + expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted']) + await ctx.fiber.dispose() + }) + + it('honors explicitly configured lock tunables', async () => { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 }) + await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' }) + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned']) + await ctx.fiber.dispose() + }) +}) + +describe('register and list', () => { + it('publishes a record readable by an independent service instance', async () => { + const first = await service() + await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' }) + + // A second instance stands in for another process reading the same file. + const reader = await service() + const listed = await reader.registry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ + sessionId: 'sess-1', + cwd: '/tmp/project', + pid: process.pid, + }) + await first.ctx.fiber.dispose() + await reader.ctx.fiber.dispose() + }) + + it('replaces an earlier record for the same session id', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' }) + const listed = await registry.list() + expect(listed).toHaveLength(1) + // The later registration wins: `cwd` distinguishes the two calls. + expect(listed[0]?.cwd).toBe('/b') + await ctx.fiber.dispose() + }) + + it('creates the registry root private and the file owner-only', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect(statSync(root).mode & 0o777).toBe(0o700) + expect(statSync(file()).mode & 0o777).toBe(0o600) + await ctx.fiber.dispose() + }) +}) + +describe('liveness pruning', () => { + it('drops a record whose process is gone', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('live'), cwd: '/a' }) + + // A real exited pid: spawn a process, wait for it, then claim its id. The + // kernel has reaped it, so signal 0 reports ESRCH. + const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']) + const deadPid = Number(dead.stdout) + expect(isPidAlive(deadPid)).toBe(false) + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') }, + ])) + + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['live']) + // The prune is durable, not just filtered in memory. + expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live']) + await ctx.fiber.dispose() + }) + + it('keeps a live record owned by another user (EPERM means alive)', () => { + const eperm = (): never => { + const error = new Error('operation not permitted') as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + expect(isPidAlive(1, eperm)).toBe(true) + }) + + it('propagates an unexpected errno instead of guessing liveness', () => { + const einval = (): never => { + const error = new Error('invalid') as NodeJS.ErrnoException + error.code = 'EINVAL' + throw error + } + expect(() => isPidAlive(1, einval)).toThrow('invalid') + }) +}) + +describe('pid recycling', () => { + it('deregistration removes only this incarnation, not a namesake pid', async () => { + const { ctx, registry } = await service() + const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' }) + + // A foreign record reusing THIS live pid under a different session and boot + // id: deregistering must not delete it. + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + + // Awaiting the disposer is the contract: the record is durably gone when it + // settles, so the assertion needs no timing slack. + await disposer() + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['other']) + await ctx.fiber.dispose() + }) +}) + +describe('file format', () => { + it('round-trips records', () => { + const records = [{ + sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'), + }] + expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true }) + }) + + it('stamps the format version', () => { + const stamped = JSON.parse(serializeRegistry([])) as { version: number } + expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION) + }) + + it.each([ + ['torn json', '{"version":0,"records":[{'], + ['a foreign version', '{"version":99,"records":[]}'], + ['a non-object root', '[]'], + ['a null root', 'null'], + ['a non-array records field', '{"version":0,"records":{}}'], + ])('reads %s as an empty, non-intact registry', (_label, text) => { + expect(parseRegistry(text)).toEqual({ records: [], intact: false }) + }) + + it.each([ + ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }], + ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }], + ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }], + ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }], + ['a non-object row', 'nonsense'], + ])('drops a row with %s but keeps its intact siblings', (_label, row) => { + const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' } + const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] }) + const parsed = parseRegistry(text) + expect(parsed.records.map(record => record.sessionId)).toEqual(['keep']) + expect(parsed.intact).toBe(false) + }) + + it('heals a damaged medium on the next locked write', async () => { + writeFileSync(file(), 'not json at all') + const { ctx, registry } = await service() + await registry.list() + expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true) + await ctx.fiber.dispose() + }) + + it('reads a missing file as no live sessions', async () => { + const { ctx, registry } = await service() + rmSync(file(), { force: true }) + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + +}) + +describe('failure reporting', () => { + it('tolerates a registry file another process created first', async () => { + // Two services racing `ensureFile`: the loser sees EEXIST, which is the + // intended outcome rather than an error, and both still publish. + const first = await service() + const second = await service() + await Promise.all([ + first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }), + second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }), + ]) + expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b']) + await first.ctx.fiber.dispose() + await second.ctx.fiber.dispose() + }) + + it('warns instead of throwing when deregistration fails during teardown', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' }) + // Make the registry path unusable, so the disposer's own write fails while the + // fiber is already unwinding. Teardown must still complete. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(ctx.fiber.dispose()).resolves.not.toThrow() + }) + + it('propagates a read failure that is not a missing file', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' }) + // A directory where the file belongs makes the read fail with EISDIR, which + // is corruption rather than "no live sessions" and must not read as empty. + rmSync(file(), { force: true }) + mkdirSync(file(), { recursive: true }) + await expect(registry.list()).rejects.toThrow() + rmSync(file(), { recursive: true, force: true }) + await ctx.fiber.dispose() + }) +}) + +describe('retitle', () => { + it('replaces the recorded title of a session this process owns', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect((await registry.list())[0]?.title).toBeUndefined() + + await registry.retitle(SessionId('sess-1'), 'first') + expect((await registry.list())[0]?.title).toBe('first') + await registry.retitle(SessionId('sess-1'), 'second') + expect((await registry.list())[0]?.title).toBe('second') + await ctx.fiber.dispose() + }) + + it('accepts a registration that already carries a title', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' }) + expect((await registry.list())[0]?.title).toBe('preset') + await ctx.fiber.dispose() + }) + + it('leaves a same-id record owned by another incarnation untouched', async () => { + const { ctx, registry } = await service() + // Same live pid, different boot id: another incarnation's record must not be + // retitled by this one. + writeFileSync(file(), serializeRegistry([ + { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + await registry.retitle(SessionId('foreign'), 'not mine') + expect((await registry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('ignores an unknown session id, since a title can resolve after removal', async () => { + const { ctx, registry } = await service() + await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined() + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) +}) + +describe('same-process concurrency', () => { + it('keeps every record when one process registers several sessions at once', async () => { + // The advisory lock is tracked per process, so same-process callers contend + // for it through its bounded retry budget instead of queueing. Past a dozen + // or so overlapping calls that budget runs out and a registration rejects — + // and callers publish fire-and-forget, so the rejection is swallowed and the + // session silently vanishes from the listing. The service therefore + // serializes its own callers; the lock only excludes other processes. + const { ctx, registry } = await service() + // Register once first so the file and directory already exist: without that, + // the concurrent calls serialize behind their own mkdir/create awaits and the + // overlap under test never happens. + await registry.register({ sessionId: SessionId('warm'), cwd: '/w' }) + const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) => + registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` }))) + + // Every call must SUCCEED, not merely leave the file consistent. Callers + // publish fire-and-forget, so a rejection is swallowed and the session + // silently vanishes from the listing rather than failing loudly. + expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([]) + const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort() + expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected) + await ctx.fiber.dispose() + }) + + it('keeps serving later callers after one cycle fails', async () => { + const { ctx, registry } = await service() + // A directory sitting where the registry file must be makes one cycle fail + // without breaking the shared chain for the calls queued behind it. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow() + + rmSync(root, { recursive: true, force: true }) + await registry.register({ sessionId: SessionId('after'), cwd: '/w' }) + expect((await registry.list()).map(record => record.sessionId)).toEqual(['after']) + await ctx.fiber.dispose() + }) +}) + +describe('cross-process concurrency', () => { + it('keeps every record when independent processes register at once', async () => { + // The regression that motivates the advisory lock: unlocked whole-file + // republication loses records under concurrent writers. Real processes are + // required — same-process promises would serialize on the event loop. + const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url)) + const count = 8 + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) + const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs') + // Source plane: tsx resolves the workspace import through the root + // tsconfig `paths` to `src`, so this runs without a build step. + const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') } + + const children = Array.from({ length: count }, (_unused, index) => + spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], { + env, + stdio: ['pipe', 'pipe', 'inherit'], + })) + try { + // Every child must have committed its record AND still be alive when the + // file is read, so the assertion sees concurrent writes rather than prunes. + await Promise.all(children.map(child => new Promise((resolve, reject) => { + child.stdout.once('data', () => { resolve() }) + child.once('error', reject) + child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) }) + }))) + + const stored = parseRegistry(readFileSync(file(), 'utf8')) + expect(stored.intact).toBe(true) + expect(stored.records.map(record => record.sessionId).sort()).toEqual( + Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(), + ) + } finally { + for (const child of children) child.stdin.end() + await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) }))) + } + }, 60_000) +}) diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json new file mode 100644 index 0000000000..0ab9c6a3a4 --- /dev/null +++ b/packages/session-registry/session-registry-file/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../session-registry" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml new file mode 100644 index 0000000000..2d4554f70c --- /dev/null +++ b/packages/session-registry/session-registry-live/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md +README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040 +README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515 diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md new file mode 100644 index 0000000000..0404915a97 --- /dev/null +++ b/packages/session-registry/session-registry-live/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +English | [中文](README.zh.md) + +Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front. + +## Behavior + +Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions. + +A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show. + +`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one. + +Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune. + +## Config + +None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record. + +#### KV Cache effect + +Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check. +- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision. diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md new file mode 100644 index 0000000000..5bfb9a90db --- /dev/null +++ b/packages/session-registry/session-registry-live/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +[English](README.md) | 中文 + +把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。 + +## 行为 + +注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。 + +会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。 + +`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。 + +发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。 + +## 配置 + +无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。 + +## 模型体验 + +无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。 + +#### KV 缓存影响 + +与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。 + +## 已知限制与延期工作 + +- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。 +- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。 diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json new file mode 100644 index 0000000000..f0c5aeddc6 --- /dev/null +++ b/packages/session-registry/session-registry-live/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-live", + "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts new file mode 100644 index 0000000000..8e34487e65 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/index.ts @@ -0,0 +1,84 @@ +/** + * Publishes every live session in this process into the cross-process session + * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than + * only the one a launcher minted up front. + * + * Mounted in a composition whose sessions come and go — the browser UI creates + * one per conversation — this plugin follows `session/created` and + * `session/disposed` instead of registering a single launcher-known identity. + * A session with no `cwd` in its header is skipped: the registry's workspace + * column would have nothing truthful to show, and a subagent child is exactly + * that case. Titles are mirrored into the record as `session/title` events + * arrive, so a reader never has to parse a backend's log format. + * @module @deepseek-ai/dsh-session-registry-live + */ + +import type { Context } from 'cordis' +import type { Session } from '@deepseek-ai/dsh-session' +// Empty type imports carry the Context merges this plugin relies on: the +// `sessionRegistry` service and the `session/title` session event. +import type {} from '@deepseek-ai/dsh-session-registry' +import type {} from '@deepseek-ai/dsh-session-title' + +/** Cordis plugin name. */ +export const name = 'session-registry-live' + +/** Services required before sessions can be followed and records published. */ +export const inject = ['sessions', 'sessionRegistry'] + +/** + * Follow session lifecycle and keep the registry in step. + * @param ctx - context carrying the session store and the registry service. + */ +export function apply(ctx: Context): void { + /** + * Per-session registration state. `'disposing'` is a tombstone written when a + * session ends while its registration is still in flight: without it the + * late-arriving disposer would be stored for a session that no longer exists + * and its record would outlive the session until a pid-based prune. + */ + const registered = new Map Promise) | 'disposing'>() + + const publish = (session: Session): void => { + const cwd = session.header.cwd + // A session without a workspace has no listable location; skipping keeps the + // registry free of rows `dsh list-sessions` could not render truthfully. + if (cwd === undefined) return + void ctx.sessionRegistry.register({ sessionId: session.id, cwd }) + .then((dispose) => { + if (registered.get(session) === 'disposing') { + registered.delete(session) + void dispose() + return + } + registered.set(session, dispose) + }) + .catch((error: unknown) => { + registered.delete(session) + ctx.logger.warn('failed to publish session %s: %s', session.id, String(error)) + }) + } + + for (const session of ctx.sessions.list()) publish(session) + ctx.on('session/created', (session) => { publish(session) }, { global: true }) + ctx.on('session/disposed', (session) => { + const entry = registered.get(session) + if (typeof entry === 'function') { + registered.delete(session) + void entry() + return + } + // Registration is still in flight; leave a tombstone for it to observe. + registered.set(session, 'disposing') + }, { global: true }) + + // Mirror title revisions onto the record. A title arrives after registration + // and may be replaced, so the listing tracks the latest logged value. + ctx.on('session/event', (session, event) => { + if (event.type !== 'session/title') return + const { title } = event.data + void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => { + ctx.logger.warn('failed to retitle %s: %s', session.id, String(error)) + }) + }, { global: true }) +} diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts new file mode 100644 index 0000000000..19d2a9f483 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`. + * @module @deepseek-ai/dsh-session-registry-live/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-live-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this plugin owns no durable state of its own — the + * uniqueness and liveness relations over published records are checked by the + * companion in `@deepseek-ai/dsh-session-registry`, which owns that file. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts new file mode 100644 index 0000000000..ec0973259e --- /dev/null +++ b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts @@ -0,0 +1,227 @@ +/** + * Tests for the live-session publisher over the REAL session store, so + * publication follows the store's actual lifecycle dispatch rather than a + * hand-built event emitter: sessions created after mount are published, + * disposal removes their records, a session without a workspace is skipped, and + * logged title revisions are mirrored onto the record so a reader never parses a + * backend's log format. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as live from '@deepseek-ai/dsh-session-registry-live' +// Empty type import carries the `session/title` event into the session-event map. +import type {} from '@deepseek-ai/dsh-session-title' + +let root: string + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) }) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +/** Mount the real store plus the publisher. */ +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + return ctx +} + +/** Let the publisher's fire-and-forget registration reach durability. */ +const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) }) + +/** Read the registry through an independent service, as `dsh list-sessions` would. */ +async function listExternally(): Promise { + const reader = new Context() + await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + const records = await reader.sessionRegistry.list() + await reader.fiber.dispose() + return records +} + +describe('publishing', () => { + it('publishes sessions that already exist when the plugin mounts', async () => { + // A composition may mount the publisher after sessions exist (a resumed + // session, or plugin order), so mount-time adoption is its own path. + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } }) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + await settle() + + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting']) + await ctx.fiber.dispose() + }) + + it('publishes a session created after mount', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } }) + await settle() + + const listed = await ctx.sessionRegistry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' }) + await ctx.fiber.dispose() + }) + + it('skips a session with no workspace, having nothing truthful to list', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('no-cwd')) + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('has no title until one is logged', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('mirrors the latest logged title onto the record', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } }) + await settle() + + session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess') + + // A revision replaces the previous value rather than accumulating. + session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title') + await ctx.fiber.dispose() + }) + + it('ignores session events other than a title revision', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } }) + await settle() + const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle') + + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await settle() + expect(retitle).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('retitles only the session that logged the event', async () => { + const ctx = await mount() + const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } }) + ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } }) + await settle() + + first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title])) + expect(byId.get(SessionId('one'))).toBe('only mine') + expect(byId.get(SessionId('two'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('publishes every concurrently created session', async () => { + const ctx = await mount() + for (let index = 0; index < 5; index += 1) { + ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } }) + } + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort()) + .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4']) + await ctx.fiber.dispose() + }) +}) + +describe('failure and race handling', () => { + it('removes the record when a session is disposed mid-registration', async () => { + // The tombstone path: the session ends before its registration resolves, so + // the late disposer must be applied instead of stored for a dead session. + const ctx = await mount() + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } }) + }, + }) + // No settle: dispose while `register` is still in flight. + await owner?.fiber.dispose() + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('warns and drops the record when publication fails', async () => { + const ctx = await mount() + ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/) + await ctx.fiber.dispose() + }) + + it('warns when a title revision cannot be recorded', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } }) + await settle() + + ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/) + await ctx.fiber.dispose() + }) +}) + +describe('disposal', () => { + it('removes a record when its own session is disposed, keeping the others', async () => { + const ctx = await mount() + // A session belongs to the fiber that created it, so a child plugin fiber + // gives one session an independent lifetime without disposing the services. + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } }) + }, + }) + ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + // Disposing only that fiber ends its session, which the publisher follows. + await owner?.fiber.dispose() + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable']) + await ctx.fiber.dispose() + }) + + it('leaves no record behind after the whole tree unloads', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } }) + ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + await ctx.fiber.dispose() + expect(await listExternally()).toEqual([]) + }) +}) diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json new file mode 100644 index 0000000000..456adfc51b --- /dev/null +++ b/packages/session-registry/session-registry-live/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + }, + { + "path": "../session-registry" + }, + { + "path": "../../session-title/session-title" + } + ] +} diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..211c8222e8 --- /dev/null +++ b/packages/session-registry/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md +README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586 +README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33 diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md new file mode 100644 index 0000000000..8ab8e232e6 --- /dev/null +++ b/packages/session-registry/session-registry/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +English | [中文](README.zh.md) + +Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service. + +## Shape + +- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability. +- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone. +- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom. + +Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +## Record vocabulary + +`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing. +- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress. diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md new file mode 100644 index 0000000000..62774e8a2a --- /dev/null +++ b/packages/session-registry/session-registry/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +[English](README.md) | 中文 + +存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。 + +## 形状 + +- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。 +- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。 +- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。 + +后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。 + +## 记录词汇 + +`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。 + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。 +- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。 diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json new file mode 100644 index 0000000000..75308999f5 --- /dev/null +++ b/packages/session-registry/session-registry/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-registry", + "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts new file mode 100644 index 0000000000..413317c31c --- /dev/null +++ b/packages/session-registry/session-registry/src/index.ts @@ -0,0 +1,82 @@ +/** + * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry + * of live `dsh` sessions, so a separate short-lived process such as + * `dsh list-sessions` can answer "what am I running right now". + * + * This package owns only the service contract and the record vocabulary; a + * backend (the lock-guarded JSON file in + * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the + * medium. Whatever the medium, liveness is part of the contract: {@link list} + * returns only records whose process existed at observation time, so a process + * killed without running its disposer leaves no permanent phantom. + * @module @deepseek-ai/dsh-session-registry + */ + +import { Context, Service } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from './types.ts' + +export { BootId } from './types.ts' +export type { SessionRegistryRecord } from './types.ts' + +declare module 'cordis' { + interface Context { + sessionRegistry: SessionRegistry + } +} + +/** What one process publishes about itself; the service supplies pid and timing. */ +export interface SessionRegistration { + /** The session this process runs. */ + sessionId: SessionId + /** Absolute workspace directory the session acts on. */ + cwd: string + /** Human-readable session title, when one already exists. */ + title?: string +} + +/** + * Cross-process live-session registry. Reads prune dead records, so every + * returned record's process existed at observation time. Backends serialize + * mutations against concurrent registrars — other processes and overlapping + * calls in this one — so records are never lost to a torn read-modify-write. + */ +export abstract class SessionRegistry extends Service { + /** This process incarnation's id, stamped into every record it publishes. */ + protected readonly bootId: BootId + + constructor(ctx: Context, bootId: BootId) { + super(ctx, 'sessionRegistry') + this.bootId = bootId + } + + /** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ + abstract register(registration: SessionRegistration): Promise<() => Promise> + + /** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ + abstract retitle(sessionId: SessionId, title: string): Promise + + /** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ + abstract list(): Promise +} + +export default SessionRegistry diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts new file mode 100644 index 0000000000..f41207fbed --- /dev/null +++ b/packages/session-registry/session-registry/src/invariant.ts @@ -0,0 +1,58 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`. + * @module @deepseek-ai/dsh-session-registry/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SessionRegistryRecord } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Cross-check every published listing against the relations the seam contract + * owns: a session id identifies at most one live record, and each listed record + * carries the identity fields a reader must be able to trust. Only a backend's + * mutation path can break either, so the check wraps the authoritative read + * rather than inspecting any medium. + * + * Liveness itself is deliberately not re-probed here. A backend derives it at + * read time, so a second probe would race the first and report a process that + * exited in between as a violation of a contract the seam never made. + */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const service = ctx.sessionRegistry + const listed = service.list.bind(service) + ctx.effect(() => { + service.list = async (): Promise => { + const records = await listed() + const seen = new Set() + for (const record of records) { + if (seen.has(record.sessionId)) { + fail(`session ${record.sessionId} appears in more than one live registry record`) + } + seen.add(record.sessionId) + // A record a reader cannot attribute to a process is unusable: `dsh list-sessions` + // renders the pid and derives liveness from it. + if (!Number.isSafeInteger(record.pid) || record.pid <= 0) { + fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`) + } + } + return records + } + return () => { service.list = listed } + }) +}, { inject: ['sessionRegistry'] }) + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts new file mode 100644 index 0000000000..6fff91648e --- /dev/null +++ b/packages/session-registry/session-registry/src/types.ts @@ -0,0 +1,55 @@ +/** + * Registry record vocabulary: the durable shape one live `dsh` process + * publishes about itself and `dsh list-sessions` reads back. + * @module @deepseek-ai/dsh-session-registry/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** + * Identifies one process incarnation. Minted per registering process, so a + * record whose `pid` was recycled by the operating system cannot be mistaken + * for the original: the boot id differs even when the pid matches. + */ +export type BootId = Branded<'BootId'> + +/** + * Brand a string as a {@link BootId}. + * @param id - the raw boot id string. + * @returns the same string, branded (a compile-time cast — no runtime cost). + */ +export function BootId(id: string): BootId { + return id as BootId +} + +/** + * One live session's self-published registration. Every field is immutable for + * the lifetime of the registration: a process publishes once at startup and + * removes the record on exit, never mutating it in place. + * + * Only top-level surfaces a user starts directly register: in-process subagents + * have no process of their own, and out-of-process subagent backends spawn + * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry. + */ +export interface SessionRegistryRecord { + /** The session this process is running. Unique across live records. */ + readonly sessionId: SessionId + /** Operating-system process id, used with `bootId` to decide liveness. */ + readonly pid: number + /** Absolute workspace directory the session acts on. */ + readonly cwd: string + /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */ + readonly startedAt: number + /** This process incarnation's id, distinguishing a recycled `pid`. */ + readonly bootId: BootId + /** + * Human-readable session title, as the registering process last knew it. + * + * Carried in the record rather than read from the session log: the log's + * location, file format, and compression are per-deployment backend choices, + * so an independent reader cannot portably parse one. Absent until a title + * exists — a fresh session has none. + */ + readonly title?: string +} diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts new file mode 100644 index 0000000000..6d97b3d129 --- /dev/null +++ b/packages/session-registry/session-registry/tests/invariant.spec.ts @@ -0,0 +1,98 @@ +/** + * Tests for the registry's invariant companion: each acceptance path is proven + * to REJECT an invalid case, since a check that cannot fail is not a check. + * The backend is a minimal in-memory stub — the companion owns contract-level + * relations over `list()` results, whatever medium serves them. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts' + +/** Minimal in-memory backend whose listings the test scripts directly. */ +class StubRegistry extends SessionRegistry { + records: SessionRegistryRecord[] = [] + + constructor(ctx: Context) { + super(ctx, BootId('stub-boot')) + } + + register(registration: SessionRegistration): Promise<() => Promise> { + this.records.push({ + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + }) + return Promise.resolve(() => Promise.resolve()) + } + + retitle(): Promise { + return Promise.resolve() + } + + list(): Promise { + return Promise.resolve([...this.records]) + } +} + +/** One record with the given identity fields, live by construction. */ +function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord { + return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) } +} + +/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */ +async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + const stub = ctx.sessionRegistry as StubRegistry + if (records !== undefined) stub.records = records + await ctx.plugin(invariant) + return { ctx, stub } +} + +describe('listing invariants', () => { + it('accepts a well-formed listing', async () => { + const { ctx } = await mount() + await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' }) + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1) + await ctx.fiber.dispose() + }) + + it('rejects a listing where one session id appears twice', async () => { + // Two live records for one session: only a broken mutation path (or an + // out-of-band writer) can produce this, and it would make + // `dsh list-sessions` show one session twice. + const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/) + await ctx.fiber.dispose() + }) + + it('rejects a listing whose record carries an unusable pid', async () => { + // A record no reader could attribute to a process: `dsh list-sessions` + // renders the pid and derives liveness from it. + const { ctx } = await mount([record('ghost', 'boot-x', 0)]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/) + await ctx.fiber.dispose() + }) + + it('stops checking, and keeps working, when the companion unloads', async () => { + // A duplicate-id listing the mounted companion rejects, so the post-disposal + // read proves the wrapper is gone rather than merely bypassed. + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')] + const companion = await ctx.plugin(invariant) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/) + + await companion.dispose() + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json new file mode 100644 index 0000000000..cca8f9282b --- /dev/null +++ b/packages/session-registry/session-registry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 3c59b2ba93..be6b79d5c5 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27 -README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac +README.md: a119656ce09863f3025f6f2ec7ffbedfb3bca4ca +README.zh.md: 736f34fec83d48b3d783b63e7bab0dec44fe1676 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 43666d2d11..a119656ce0 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. `prepareCwd` runs after the `workspace/` fixture is copied and before the child boots, for world state a committed fixture cannot carry: git never tracks an entry named `.git`, and `.gitignore` excludes every `worktrees/` directory, so a repository-shaped scenario commits the file bodies under representable names and the hook assembles the real layout from them. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 608b2490f5..736f34fec8 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。`prepareCwd` 在复制 `workspace/` fixture 后、子级启动前运行,用于提供已提交 fixture 无法承载的环境状态:Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此仓库形态的场景会以可表示的名称提交文件内容,并由该钩子据此组装真实布局。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d11a4f1b9c..56df34e80f 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,6 +155,13 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Optional setup run in the generated cwd after {@link workspaceDir} is + * copied and before the child boots — for world state a committed fixture + * cannot express, such as a `.git` entry (git never tracks that name, so a + * repository-shaped fixture has to be materialized at run time). + */ + prepareCwd?: (cwd: string) => Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } + await opts.prepareCwd?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index d04a481993..efc1165c45 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -130,6 +130,12 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** + * Setup run in the generated cwd after the `workspace/` fixture is copied and + * before the child boots, for world state a committed fixture cannot express + * — a `.git` entry, which git never tracks under that name. + */ + prepareCwd?: (cwd: string) => Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -981,6 +987,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, + ...scenario.prepareCwd !== undefined ? { prepareCwd: scenario.prepareCwd } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl index d0242ae39f..bed4c47530 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:prepared.marker,seed.txt"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 420dcef2cf..a8dec942ae 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,5 +1,5 @@ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + prepareCwd: async (cwd) => { await writeFile(join(cwd, 'prepared.marker'), 'prepared\n') }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..d58dfc1575 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31 +README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..2ccf03a2b3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -11,8 +11,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | -| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..15c13adad5 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -11,8 +11,7 @@ | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | -| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 4644912304..9882d22603 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -156,17 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** - * Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume - * session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)` - * makes `id` readable as the bare identifier `resumeSessionId` in a config - * `!!js` expression. The value is the bin's already-parsed id (or `undefined`), - * so resuming a session needs no environment variable. A bin that never - * provides it leaves the identifier undeclared, so configs read it defensively - * (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`). - */ -export const RESUME_SESSION_ID_KEY = 'resumeSessionId' - /** * Boot the Loader against `absoluteConfigPath` and return only after the whole * tree settles. Entry names load through the Loader's internal module loader diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70de732701..e3be18db21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,15 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../packages/session-projection/session-projection-cache + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-file + '@deepseek-ai/dsh-session-registry-live': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-live '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -454,6 +463,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:* + version: link:../packages/bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -553,6 +565,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-source-guard': + specifier: workspace:* + version: link:../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -586,6 +601,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:* + version: link:../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -1883,6 +1901,37 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/tmux-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/workspace-context: dependencies: schemastery: @@ -2742,6 +2791,49 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/source-guard: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/dsh-bash': @@ -3890,6 +3982,70 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-registry/session-registry: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-file: + dependencies: + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + schemastery: + specifier: ^3.15.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-live: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../session-registry-file + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-title/session-title: dependencies: schemastery: @@ -8000,6 +8156,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -9103,6 +9262,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -10090,6 +10252,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -10193,6 +10358,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -10334,6 +10503,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -12903,6 +13075,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -14204,6 +14380,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -15384,6 +15562,12 @@ snapshots: process-nextick-args@2.0.1: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} protobufjs@7.6.4: @@ -15533,6 +15717,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + retry@0.13.1: {} rfdc@1.4.1: {} @@ -15769,6 +15955,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0f3270f8ee..90a5bef153 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -229,6 +229,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', + SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md', + SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8846cca3eb..6df0c9acee 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -171,6 +171,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['apiproxy'], note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, + { + key: 'sessionRegistry', + pkg: 'session-registry', + title: 'Live-session registry', + mode: 'seam', + implementations: ['session-registry-file'], + consumers: ['session-registry-live'], + note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fd74c6d13e..58ac34a3cf 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -98,6 +98,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, + 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' }, + 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' }, + 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, diff --git a/skills/dsh-migrate/SKILL.md b/skills/dsh-migrate/SKILL.md new file mode 100644 index 0000000000..fbea31f04f --- /dev/null +++ b/skills/dsh-migrate/SKILL.md @@ -0,0 +1,57 @@ +--- +name: dsh-migrate +description: Migrate a user's setup from another coding agent (opencode, pi, Claude Code, Codex) to DSH — porting instruction files, custom commands and skills, hooks, MCP servers, and API/env configuration into their DSH equivalents. Use when the user asks to migrate, switch, or move from another coding agent to DSH. +--- + +# DSH Migrate + +Move a user's existing coding-agent setup onto DSH: instruction files, custom commands, skills, hooks, MCP servers, and API/environment configuration. Port only what has a real DSH equivalent; tell the user plainly when something has none. + +## First: identify the source + +Ask which agent the user is migrating from if they have not said: **opencode**, **pi**, **Claude Code**, or **Codex**. The mapping differs per source. Then locate that agent's config (ask the user, or inspect the obvious locations: `~/.claude/` and `.claude/` for Claude Code, `~/.codex/` and `.codex/` for Codex, the opencode/pi config dir the user names). Read what exists before proposing changes; never invent files the user does not have. + +## DSH targets + +Every migration lands in one of these DSH surfaces. Verify the exact path against the running install rather than assuming. + +- **Workspace instructions**: DSH reads `AGENTS.md` and `CLAUDE.md` (and `AGENTS.local.md` / `CLAUDE.local.md`) from the project, walking up to the project root, plus a user-global `~/.dsh/AGENTS.md`. `CLAUDE.md` is read as-is, so a Claude Code project needs no rename. +- **Personal overlay** (user-global, applies to every DSH session): the Harness home `~/.dsh/` holds `config.yaml` (a top-level YAML array of Loader patch entries that patch the booted plugin tree), `.env` (fills environment gaps only — ambient env and the invoking directory's `.env` win), `AGENTS.md`, and `skills/`. +- **Skills**: directory-bundle or flat-Markdown skills load from `.dsh/skills/` and `.agents/skills/` in the project, and `~/.dsh/skills/` and `~/.agents/skills/` for the user. Personal skills go in `~/.dsh/skills//SKILL.md`. Use the `skill-creator` skill to author them. +- **Hooks**: DSH runs a mapped subset of an existing Claude Code or Codex hook config through compatibility bridges — no rewrite needed for the supported subset. See the per-source sections. +- **MCP servers**: DSH has no native MCP client. Reach MCP servers through the `mcporter` skill / CLI, which can call servers already configured for other tools. +- **API / model config**: DSH uses `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from `.env` (root, invoking directory, or `~/.dsh/.env`). Model and provider are chosen in the booted `cordis.yml` / personal overlay, not per-provider config files. + +## Per-source mapping + +### Claude Code + +- `CLAUDE.md` → read as-is by DSH workspace instructions; keep it, or consolidate into `AGENTS.md`. User-global rules → `~/.dsh/AGENTS.md`. +- `.claude/hooks.json` (or a settings file's `hooks` key) → the `@deepseek-ai/dsh-hooks-claude` bridge runs the mapped command-hook subset on DSH's interception seams, with `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution. Add it to the booted `cordis.yml` (or personal overlay) pointing `configPath` at the existing file. Anything outside the mapped subset should become a native DSH plugin, not a shimmed hook. +- Slash commands → DSH commands are plugin-provided; there is no drop-in import. Reimplement genuinely needed ones as skills (`~/.dsh/skills/`) or plugins. +- MCP servers in Claude config → use `mcporter` to reach them; DSH has no native MCP. +- `ANTHROPIC_API_KEY` etc. do not transfer; DSH is DeepSeek-backed via `DEEPSEEK_API_KEY`. + +### Codex + +- Codex `AGENTS.md` → DSH already reads `AGENTS.md`; keep it. User-global → `~/.dsh/AGENTS.md`. +- Codex hook config → the `@deepseek-ai/dsh-hooks-codex` bridge runs a deliberate subset (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`; regex-only matchers; no plugin env injection; no pre-tool approval/rewrite). Add the bridge to the booted config with `configPath` at the existing Codex hooks file. State the unsupported points to the user rather than implying full parity. +- MCP servers → `mcporter`. +- API/env → `DEEPSEEK_API_KEY` in `.env`. + +### opencode / pi + +- These have no compatibility bridge. Port by concept, not by file: + - Agent/system instructions → `AGENTS.md` (project) and `~/.dsh/AGENTS.md` (user-global). + - Provider/model and any plugin-style tuning → the booted `cordis.yml` or `~/.dsh/config.yaml` overlay patches; API keys → `.env`. + - Reusable prompts/commands → skills under `~/.dsh/skills/`. + - MCP servers → `mcporter`. +- pi has no native MCP by design; the `mcporter` route is the same as for DSH. + +## Do the migration + +1. Confirm the source agent and read its actual config. +2. For each capability (instructions, hooks, commands/skills, MCP, API/env), map it to the DSH target above, or tell the user it has no equivalent. +3. Write the ported files (`AGENTS.md`, `~/.dsh/AGENTS.md`, `~/.dsh/config.yaml`, `~/.dsh/.env`, skills). For hook bridges, add the plugin entry to the booted config. +4. Verify: hooks need the bridge plugin present in the running tree; MCP needs `mcporter` reachable; API needs `DEEPSEEK_API_KEY` set. Test in a real DSH session, not just on paper. +5. Summarize what was ported, what was reimplemented, and what has no DSH equivalent. diff --git a/tsconfig.base.json b/tsconfig.base.json index ca4fc68b58..62033becd0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,7 @@ "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/session-registry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -178,6 +179,7 @@ "./packages/session-persistence/*/src", "./packages/session-projection/*/src", "./packages/session-query/*/src", + "./packages/session-registry/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..71157bca2a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,6 +86,7 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -165,6 +166,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, + { "path": "./packages/guard/source-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, @@ -182,6 +184,9 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, + { "path": "./packages/session-registry/session-registry" }, + { "path": "./packages/session-registry/session-registry-file" }, + { "path": "./packages/session-registry/session-registry-live" }, { "path": "./apps/cli" } ] }