From b4a130448976f18c3f7aeac0427562709fbba08a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 16:57:26 +0800 Subject: [PATCH 01/16] Rename Cordis tools for temporary plugins Clarify process-local lifecycle semantics and refresh generated documentation, demos, and snapshots. --- ...0-canonical-tool-output-contract.i18n.yaml | 6 +- ...26-07-20-canonical-tool-output-contract.md | 2 +- ...07-20-canonical-tool-output-contract.zh.md | 2 +- ...-self-referential-cordis-toolset.i18n.yaml | 6 +- ...6-07-08-self-referential-cordis-toolset.md | 26 +- ...7-08-self-referential-cordis-toolset.zh.md | 26 +- ...-20-code-mode-typed-tool-returns.i18n.yaml | 6 +- ...2026-07-20-code-mode-typed-tool-returns.md | 2 +- ...6-07-20-code-mode-typed-tool-returns.zh.md | 2 +- ...04-prune-dead-core-spine-surface.i18n.yaml | 6 +- ...026-07-04-prune-dead-core-spine-surface.md | 2 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 2 +- docs/tool-catalog.md | 56 +- examples/README.i18n.yaml | 6 +- examples/README.md | 2 +- examples/README.zh.md | 2 +- .../snapshots/advanced-toolchain/input.json | 2 +- .../advanced-toolchain/session.jsonl | 36 +- .../system-prompt.expected.md | 30 +- .../tool-schemas.expected.json | 42 +- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../fs-escalation-approved/session.jsonl | 6 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../session-query-spill/session.jsonl | 2 +- examples/cordis-agent/README.i18n.yaml | 6 +- examples/cordis-agent/README.md | 14 +- examples/cordis-agent/README.zh.md | 12 +- examples/cordis-agent/composition.md | 2 +- examples/cordis-agent/cordis.yml | 18 +- .../cordis-agent/tests/cordis-tools.e2e.ts | 32 +- examples/cordis-agent/tests/harness.ts | 4 +- .../headless-agent/tests/code-mode.e2e.ts | 18 +- .../snapshots/advanced-toolchain/input.json | 2 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 38 +- .../stream-json.expected.jsonl | 36 +- .../tests/snapshots/pty-tools/session.jsonl | 2 +- .../cordis-dynamic-toolchain/session.jsonl | 32 +- .../terminal.expected.txt | 90 +- examples/tui-agent/tests/tui.snapshot.ts | 2 +- packages/cordis/README.i18n.yaml | 6 +- packages/cordis/README.md | 2 +- packages/cordis/README.zh.md | 4 +- packages/cordis/tool-cordis/README.i18n.yaml | 6 +- packages/cordis/tool-cordis/README.md | 28 +- packages/cordis/tool-cordis/README.zh.md | 28 +- packages/cordis/tool-cordis/src/guard.ts | 2 +- packages/cordis/tool-cordis/src/index.ts | 93 +- packages/cordis/tool-cordis/src/inspect.ts | 17 +- packages/cordis/tool-cordis/src/mount.ts | 4 +- packages/cordis/tool-cordis/src/present.ts | 12 +- packages/cordis/tool-cordis/src/sandbox.ts | 12 +- .../tool-cordis/tests/cross-mount.spec.ts | 68 +- .../cordis/tool-cordis/tests/inspect.spec.ts | 13 +- .../tool-cordis/tests/integration.spec.ts | 41 +- .../cordis/tool-cordis/tests/mount.spec.ts | 117 +- .../cordis/tool-cordis/tests/present.spec.ts | 18 +- .../tool-cordis/tests/sandbox-context.spec.ts | 32 +- .../tool-cordis/tests/tool-cordis.spec.ts | 5 +- .../tool-cordis/tests/unmount-hmr.spec.ts | 34 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- .../cordis-tools-pending.expected.txt | 8 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 4 +- scripts/smoke-python-runtime.py | 35 +- .../advanced/result.json | 1436 +++++++++++++---- .../advanced/session.1.jsonl | 23 +- .../advanced/session.2.jsonl | 23 +- .../advanced/session.jsonl | 130 +- 73 files changed, 1807 insertions(+), 1002 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 14a271f3c7..f4d8021b53 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.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-20-canonical-tool-output-contract.md: 6b5cd089fcf206e659c7b67b8a996bfe81d0c333 -2026-07-20-canonical-tool-output-contract.zh.md: 61b25b14ca6f048b73a51788f112165745ae7106 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +2026-07-20-canonical-tool-output-contract.md: 822fd0fae02be62d83aa0402cdf6d7d4322b89fb +2026-07-20-canonical-tool-output-contract.zh.md: 3d32114107732c48cb0cb236faa8ec60ad887698 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 6b5cd089fc..822fd0fae0 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -56,7 +56,7 @@ The first-party tools preserve their existing Native text while returning domain | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed dynamic-mount handles | +| `cordis_inspect` / `cordis_try` / `cordis_stop` | Inspection text or typed temporary-Plugin handles | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index 61b25b14ca..3d32114107 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -56,7 +56,7 @@ type ToolExecutionResult = | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 | +| `cordis_inspect` / `cordis_try` / `cordis_stop` | 检查文本或类型化的临时 Plugin 句柄 | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index f91e3b5d42..e9afb7ed05 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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-08-self-referential-cordis-toolset.md: 80bffa3a2a959939f18fd1d3422607cf61895fc7 -2026-07-08-self-referential-cordis-toolset.zh.md: 2ec79037045fdb040cccf31699789abdd3a12db2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +2026-07-08-self-referential-cordis-toolset.md: de469f18cbddd42249e5d459949b745634b4e399 +2026-07-08-self-referential-cordis-toolset.zh.md: 7fdf438b6c144d152d3e55a07d416db9d5a5c0c6 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 80bffa3a2a..de469f18cb 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,19 +12,19 @@ First, model-written registration must be validated where it happens: a malforme ## Decision -The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again. +The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, try an in-memory temporary Plugin, and stop that Plugin to quiescence. -The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a mount can call `ctx.bash` to run commands with the host executor's privileges and can reach the real filesystem and web services. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. +The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. ### The three tools | Tool | Contract | |---|---| -| `cordis_inspect` | Read-only report over the live runtime, one Markdown section per `what` value (omit `what` for all sections). An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. Never mutates. | -| `cordis_mount` | Evaluates `code` (the body of an async JavaScript function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted as a child of the `cordis-dynamic` group fiber and tracked under a fresh id (`dyn-1`, `dyn-2`, …). | -| `cordis_unmount` | Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. | +| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_try`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. | +| `cordis_try` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). | +| `cordis_stop` | Stops one `cordis_try` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. | -`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. +`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_try` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_stop`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. ### Sandbox semantics @@ -36,9 +36,11 @@ Mount code crosses the vm boundary through three controls. Dual-realm `instanceo The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. -### The dynamic group and mount lifecycle +### The internal group and temporary-Plugin lifecycle -All dynamic mounts are children of one `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles reload and unload. Mounting awaits settlement; startup failure disposes the fiber before returning an error. A settled pending mount remains visible with its missing injections. `cordis_unmount` awaits the mount fiber's disposal. +Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_try` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_stop` awaits the Plugin fiber's disposal. + +Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ### Cross-mount composition via provide/inject @@ -52,15 +54,15 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( ### Configuration, rendering, and observability -The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of mount-code evaluation. Tool names, the `cordis-dynamic` group name, and the `dyn-` id prefix are structural vocabulary and stay fixed. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md) (`cordis_inspect` a `read`, `cordis_mount` an `execute` carrying the code as `rawInput`, `cordis_unmount` a `delete`), with no `presentResult` overrides. +The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. -Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own `tool/call` / `tool/result` pair, which the loop logs, and the changed tool set a mount induces is logged by the full changed request header the loop emits when schemas change between steps. There is deliberately no `cordis/mount` provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins. +Model-visible ⟺ logged holds with no new session event type: try and stop are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. ## Alternatives considered -**A structured per-capability registration tool instead of `cordis_mount`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once. +**A structured per-capability registration tool instead of `cordis_try`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once. -| Dimension | Structured per-capability tools | Single `cordis_mount` | +| Dimension | Structured per-capability tools | Single `cordis_try` | |---|---|---| | Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors | | The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration | diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 2ec7903704..7fdf438b6c 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,19 +12,19 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 cordis 运行时:审视它、将模型编写的插件挂载进去、再将其释放。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、尝试一个仅存于内存的临时 Plugin,再让该 Plugin 完全停稳。 -vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 +vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 ### 三个工具 | 工具 | 契约 | |---|---| -| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。精确的 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。从不产生变更。 | -| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数的函数体);代码必须 `return` 一个 cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新 id(`dyn-1`、`dyn-2`……)跟踪。 | -| `cordis_unmount` | 按 id 释放一个动态挂载,并等到释放达到完全停稳后才返回——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 | +| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_try` 创建的临时 Plugin。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 | +| `cordis_try` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的 Plugin 挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 | +| `cordis_stop` | 按 id 停止一个 `cordis_try` 临时 Plugin,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、配置或已安装的 Plugin。 | -`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的所有者会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)和 `events`(harness 事件及其分发模式和签名)。宽泛的 `api` 和 `events` 报告省略完整 JSDoc 以保持紧凑;精确 `name` 会返回一个服务或事件,以及其原始方法/声明 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../../docs/tool-catalog.md)是其完整呈现。 +`cordis_inspect` 的段落是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活 Plugin fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_try` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时 Plugin 可跨后续 turn 保持活跃,并在 `cordis_stop`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。[生成的工具目录](../../../../docs/tool-catalog.md)完整呈现面向模型的调用契约。 ### 沙箱语义 @@ -36,9 +36,11 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 边界将无歧义的 JSON-Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`;无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。 -### 动态分组与挂载生命周期 +### 内部分组与临时 Plugin 生命周期 -所有动态挂载都是工具插件下方 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理重载和卸载。挂载会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的释放完成。 +每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_try` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_stop` 等待 Plugin fiber 的释放完成。 + +临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 ### 通过 provide/inject 实现跨挂载组合 @@ -52,15 +54,15 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。 -「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时发出的完整变更 request header 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。 +「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:try 与 stop 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 ## 曾考虑的替代方案 -**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 +**用结构化的逐能力注册工具替代 `cordis_try`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 -| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | +| 维度 | 结构化逐能力工具 | 单一 `cordis_try` | |---|---|---| | Schema 正确性 | `parameters` 仍然是模型编写的 JSON,需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 | | 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 | diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 6f3f732cc1..0dc17721bf 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.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-20-code-mode-typed-tool-returns.md: 3d8642d66baf521f22dfb1ea0ef3e64683f918d4 -2026-07-20-code-mode-typed-tool-returns.zh.md: fea1be3e236c0ccba729e449ab9714ed497d900a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +2026-07-20-code-mode-typed-tool-returns.md: 4d60954b372253f51a2be61a12df153789239d14 +2026-07-20-code-mode-typed-tool-returns.zh.md: 562937b88ccce30d7101f8807144ec03f7ad67b4 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 3d8642d66b..4d60954b37 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -69,7 +69,7 @@ Compute time, wall time, worker heap, cancellation, and fresh-worker isolation r Background producers return a typed canonical handle such as `{ kind: 'background', taskId }` while retaining their established Native sentence. A pre-aborted background call remains a failure because successful output promises an id and no task was created. After `ctx.tasks.start()` publishes the id, task-owned cancellation governs the work: settlement or later cancellation of the enclosing `run_code` call does not kill it. A later program can pass the returned id to `task_output`, and `task_kill`, owner disposal, or service teardown owns cancellation. Foreground execution remains coupled to the call signal. The task lifetime contract is owned by the [background task runtime note](../architecture/2026-06-20-generic-long-running-tool-runtime.md). -Dynamic Cordis mounting follows the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence. +Temporary Cordis Plugins follow the same rule: `cordis_try` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `temporary.id`, inspect active or pending state, and pass that id to `cordis_stop` without parsing the stable Native sentence. ### Persistence, metadata, and spill diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index fea1be3e23..562937b88c 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -69,7 +69,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper 后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 -动态 Cordis 挂载遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 +临时 Cordis Plugin 遵循同一规则:`cordis_try` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `temporary.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_stop`,无需解析稳定的 Native 语句。 ### 持久化、元数据与输出落盘 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 19915e8383..93ab3ea3ae 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.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-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3 -2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e +# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +2026-07-04-prune-dead-core-spine-surface.md: 10433886d6e8763ca498cb42e0f6b8f35f8c0beb +2026-07-04-prune-dead-core-spine-surface.zh.md: 4502aaebf8b023ca2e43581d25c28eb43e0e73f9 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index a6c6086174..10433886d6 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -8,7 +8,7 @@ English | [中文](2026-07-04-prune-dead-core-spine-surface.zh.md) Several package-root exports, result fields, and convenience methods have no production consumer. They survive because tests import internals through public entry points or because a type anticipated a caller that never arrived. Each item is small in isolation, but together they enlarge the SDK contract, generated catalogs, documentation, and regression matrix without enabling a shipped path. -The production corpus is `packages/*/*/src`, example sources/config, and runtime scripts. Tests, package READMEs, and Agent Note prose are evidence of publication but not fixed callers. `cordis_inspect` makes `packages/cordis/tool-cordis/src/api-catalog.ts` model-visible, and `cordis_mount` can invoke injected services through guarded real-service proxies, so catalogued service methods and returned shapes are a genuine dynamic product surface. The table therefore distinguishes absence of a fixed repository caller from unreachability: rows touching catalogued vocabulary intentionally contract what model-written mounts can discover and call, while package-root implementation helpers are not reached through that service façade. Exact-symbol searches produce the following inventory: +The production corpus is `packages/*/*/src`, example sources/config, and runtime scripts. Tests, package READMEs, and Agent Note prose are evidence of publication but not fixed callers. `cordis_inspect` makes `packages/cordis/tool-cordis/src/api-catalog.ts` model-visible, and `cordis_try` can invoke injected services through guarded real-service proxies, so catalogued service methods and returned shapes are a genuine dynamic product surface. The table therefore distinguishes absence of a fixed repository caller from unreachability: rows touching catalogued vocabulary intentionally contract what model-written mounts can discover and call, while package-root implementation helpers are not reached through that service façade. Exact-symbol searches produce the following inventory: | Surface | Production evidence | Simplification | | --- | --- | --- | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 83603d8a84..4502aaebf8 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -8,7 +8,7 @@ Status: proposed 若干包(package)根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 -生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_try` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: | 接口 | 生产证据 | 简化方式 | | --- | --- | --- | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 311791e594..a09ed98714 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_stop`, `cordis_try` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -207,7 +207,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `cordis_inspect` -Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. +Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. ```json { @@ -220,7 +220,7 @@ Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: "services", "plugins", "tools", - "dynamic", + "temporary", "api", "events" ] @@ -235,30 +235,9 @@ Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -### `cordis_mount` +### `cordis_stop` -Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. - -```json -{ - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Body of an async JS function; must `return` the plugin to mount." - } - }, - "required": [ - "code" - ] -} -``` - -Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) - -### `cordis_unmount` - -Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). +Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. ```json { @@ -266,7 +245,7 @@ Dispose a plugin previously mounted with cordis_mount, by id. All its registrati "properties": { "id": { "type": "string", - "description": "The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")." + "description": "The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart." } }, "required": [ @@ -277,7 +256,28 @@ Dispose a plugin previously mounted with cordis_mount, by id. All its registrati Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. +### `cordis_try` + +Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. + +```json +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "JavaScript body returning a temporary Plugin; evaluated now and saved nowhere." + } + }, + "required": [ + "code" + ] +} +``` + +Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) + +Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index a96133c3c8..46da3a88bf 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.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 -README.md: 8fd261e624771dc568582b6d22e9985072a06715 -README.zh.md: 2ff0d0bef382435588fce01c23aa7b74e1a149b5 +# pnpm run verify-translation-pairing --write examples/README.md +README.md: 7e996d206c0e24bf40abf6244ca57afd5019f728 +README.zh.md: adb6a20e9d27da341e7f731f90c42e190961ac37 diff --git a/examples/README.md b/examples/README.md index 8fd261e624..7e996d206c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,7 +22,7 @@ An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foregr ## cordis-agent -The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. +The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_try` / `cordis_stop`) let the agent inspect the current DSH process, try model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and stop them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. diff --git a/examples/README.zh.md b/examples/README.zh.md index 2ff0d0bef3..adb6a20e9d 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -22,7 +22,7 @@ ## cordis-agent -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查自身所在的实时 cordis 运行时,将模型编写的插件挂载到其中(事件监听器、一个专为自身创建的全新工具,或一个供另一挂载项注入的服务),并再次释放它们。所有动态挂载都归入同一 `cordis-dynamic` fiber 子树。`ctx.fs`/`ctx.web` 服务仅作为提供方随行,是这些插件构建所依赖的能力。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_try`/`cordis_stop`)使 agent 可以检查当前 DSH 进程、尝试模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次停止它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 运行:`pnpm run demo:cordis`(需要 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json index b66945d9a7..f170f78a75 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK." } + { "op": "prompt", "text": "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK." } ] } diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index ffb8382e0e..301391a4dd 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,29 +1,29 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":22,"time":1785036891166,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}} -{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":22,"time":1785036891166,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785036891171,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785036891175,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -47,13 +47,13 @@ {"type":"step/end","seq":45,"time":1785036891786,"data":{"turn":1,"step":4}} {"type":"step/start","seq":46,"time":1785036891789,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":51,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785036891796,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785036891796,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"assistant/message","seq":52,"time":1785036891796,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1785036891796,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785036891799,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785036891801,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3a37f3da6a..ea24846b01 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -56,23 +56,23 @@ interface ToolArgsMap { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; } & Record; - /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ + /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ cordis_inspect: { /** Limit the report to one section. Omit for all sections. */ - what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; + what?: "services" | "plugins" | "tools" | "temporary" | "api" | "events"; /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record; - /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ - cordis_mount: { - /** Body of an async JS function; must `return` the plugin to mount. */ - code: string; - } & Record; - /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */ - cordis_unmount: { - /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ + /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */ + cordis_stop: { + /** The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart. */ id: string; } & Record; + /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + cordis_try: { + /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */ + code: string; + } & Record; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal: { /** The concrete completion objective inferred from the direct human request. */ @@ -248,17 +248,17 @@ interface ToolOutputMap { }; }; cordis_inspect: string; - cordis_mount: { + cordis_stop: { + id: string; + pluginName: string; + }; + cordis_try: { id: string; pluginName: string; state: "pending" | "loading" | "active" | "failed" | "disposed" | "unloading"; provides: string[]; waitingFor: string[]; }; - cordis_unmount: { - id: string; - pluginName: string; - }; create_goal: { goal: null; } | { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 1abccfd566..dd9558621b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -47,7 +47,7 @@ }, { "name": "cordis_inspect", - "description": "Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", + "description": "Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", "parameters": { "type": "object", "properties": { @@ -58,7 +58,7 @@ "services", "plugins", "tools", - "dynamic", + "temporary", "api", "events" ] @@ -71,30 +71,14 @@ } }, { - "name": "cordis_mount", - "description": "Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", - "parameters": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Body of an async JS function; must `return` the plugin to mount." - } - }, - "required": [ - "code" - ] - } - }, - { - "name": "cordis_unmount", - "description": "Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).", + "name": "cordis_stop", + "description": "Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.", "parameters": { "type": "object", "properties": { "id": { "type": "string", - "description": "The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")." + "description": "The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart." } }, "required": [ @@ -102,6 +86,22 @@ ] } }, + { + "name": "cordis_try", + "description": "Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "JavaScript body returning a temporary Plugin; evaluated now and saved nowhere." + } + }, + "required": [ + "code" + ] + } + }, { "name": "create_goal", "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 833ed36355..0f08c18245 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5747fa727e10/57c2f8c3fbf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-8398dc5565aa/cad9e2509e5d-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 602e5aeb0d..dd5e167660 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"d7e97e44-02ae-486e-8fb9-17a1b080a3ed","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"d7e97e44-02ae-486e-8fb9-17a1b080a3ed","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index e2970ebad1..c12398b099 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":152,"time":1784821263293,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],"surfaceOp":"append"} {"type":"tool/call","seq":153,"time":1784821263294,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","outcome":"rejected"}} +{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"f9e0a1a7-7864-4397-a1df-c1ce37f41426","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"f9e0a1a7-7864-4397-a1df-c1ce37f41426","outcome":"rejected"}} {"type":"tool/result","seq":156,"time":1784821263302,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[153],"surfaceOp":"append"} {"type":"step/end","seq":157,"time":1784821263307,"data":{"turn":1,"step":1}} {"type":"step/start","seq":158,"time":1784821263307,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 5b32c37cb6..d294525484 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -14,9 +14,9 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"9439e616-fcb2-498e-a7ee-0488c378bccf","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"9439e616-fcb2-498e-a7ee-0488c378bccf","outcome":"allowed-once"}} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 76a172d5cf..82397bcff1 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -16,8 +16,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"5583b42d-96b8-4a43-9064-12fe05f62a24","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"5583b42d-96b8-4a43-9064-12fe05f62a24","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 26fd2bf5c1..e540dfbd83 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785140680525,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36007 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-5ca5c1bc368d/3127fda07f8f-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml index 7f4f2ae9dd..b63e3c2966 100644 --- a/examples/cordis-agent/README.i18n.yaml +++ b/examples/cordis-agent/README.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 -README.md: 1309fe9b2935d3224f097ceb2e80501c8075a933 -README.zh.md: 2e3e7d7206d0d676ae7d9c9b3a2c2f8be26aafe7 +# pnpm run verify-translation-pairing --write examples/cordis-agent/README.md +README.md: cabdd063094b520039d1ca84d22e3471198df1b1 +README.zh.md: 346c4ef93634e4e917178c98c10e727a04682cda diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 1309fe9b29..cabdd06309 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, try in-memory temporary Plugins, and stop them. Temporary Plugins remain active across turns but disappear on stop, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it @@ -16,16 +16,16 @@ pnpm run demo:cordis The intended demo is staged — verify the listener link first, then let the agent extend itself: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) +> Try a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) [cordis:dyn-1] status → … ← the mounted listener firing, live > Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. - [tool call] cordis_unmount({"id": "dyn-1"}) +> Stop both temporary Plugins. + [tool call] cordis_stop({"id": "dyn-1"}) ``` Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index 2e3e7d7206..346c4ef936 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -16,16 +16,16 @@ pnpm run demo:cordis 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) +> Try a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) [cordis:dyn-1] status → … ← the mounted listener firing, live > Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. - [tool call] cordis_unmount({"id": "dyn-1"}) +> Stop both temporary Plugins. + [tool call] cordis_stop({"id": "dyn-1"}) ``` 请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..9ef98c12a4 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -3,7 +3,7 @@ # Cordis Agent App Composition -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it. +The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and try or stop in-memory temporary Plugins. ```mermaid flowchart LR diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..c4644baa53 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,9 +1,9 @@ # Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/mount/API/event state, mount a model-written plugin under -# `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored +# service/plugin/tool/temporary/API/event state, try a model-written temporary +# Plugin, and quiescently stop it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. # Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; mounted code can reach live capabilities +# access but are not a security boundary; temporary Plugin code reaches live capabilities # such as `ctx.bash`. Grant this toolset like bash access. See # ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. @@ -60,7 +60,7 @@ persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' + welcome: 'cordis-agent ready. Ask it to inspect its runtime, try a temporary listener, or invent a temporary tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the {{model}} model. @@ -68,13 +68,15 @@ You run INSIDE a cordis plugin runtime, and your cordis_* tools operate on that live runtime: cordis_inspect to look around (its `api` and `events` sections document the service methods, type shapes, and events - your plugin code can use), cordis_mount to add a plugin (an event - listener, a brand-new tool for yourself, or a service other mounts - inject), cordis_unmount to clean one up. In mounted code, NEVER use Node + your Plugin code can use), cordis_try to try an in-memory temporary + Plugin (an event listener, a brand-new tool for yourself, or a service + another temporary Plugin injects), cordis_stop to clean one up. These + Plugins remain across turns but disappear on stop, toolset unload, or + DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node built-ins (require/setTimeout/fetch) — use the runtime's cordis services via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small single-purpose plugins, prefer plain notification events over waterfall - events unless you intend to intercept, and unmount what you no longer + events unless you intend to intercept, and stop what you no longer need. Report results briefly. # The self-referential cordis toolset (loaded after the app so ctx.tools exists). diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index b5f8e27564..9402f1bba5 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -8,7 +8,7 @@ const testToolSignal = new AbortController().signal /** * With-key smoke for the self-referential cordis tools: a REAL model drives - * cordis_mount/cordis_unmount against the live context the test observes. + * cordis_try/cordis_stop against the live context the test observes. * World-verified, not self-reported: the mounted listener must actually WRITE * its tagged console line, the self-made tool must actually EXIST in the * registry and appear as a real `tool/call`, the cross-mount service must @@ -37,15 +37,15 @@ function resultText(result: { content: { type: string; text?: string }[] }): str } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { + it('tries a temporary status listener whose tagged output actually fires, then stops it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.followup([{ type: 'text', - text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' - + 'cordis event and logs every change with console.log. Reply "mounted" once done.', + text: 'Use cordis_try to create a temporary Plugin that listens to the \'agent/status\' ' + + 'Cordis event and logs every change with console.log. Reply "running" once done.', }]) await waitForIdle(ctx, agent) @@ -54,18 +54,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(taggedCalls(log).length).toBeGreaterThan(0) const mid = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(mid)).toContain('dyn-') - agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup([{ type: 'text', text: 'Now stop the temporary Plugin you just tried.' }]) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) - expect(resultText(after)).toContain('(no dynamic plugins mounted)') + expect(resultText(after)).toContain('No temporary Plugins are running.') }, 120_000) it('builds itself a reverse_text tool and actually calls it', async () => { @@ -74,7 +74,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif agent.followup([{ type: 'text', - text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + text: 'Give yourself a new tool: use cordis_try to create a temporary Plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'to register a tool named reverse_text with one required string parameter ' + '"text", returning the text reversed. Then CALL reverse_text with the ' @@ -88,7 +88,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(ctx.tools.get('reverse_text')).toBeDefined() const events = [...agent.session.events] const calls = events.filter(event => event.type === 'tool/call') - expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true) + expect(calls.some(event => event.data.name === 'cordis_try')).toBe(true) const reverseCalls = calls.filter(event => event.data.name === 'reverse_text') expect(reverseCalls.length).toBeGreaterThan(0) const reverseResults = events @@ -98,7 +98,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif // On failure, surface what the model actually mounted and what the tool // returned — an e2e failing at a distance is undebuggable without it. const mountCode = calls - .filter(event => event.data.name === 'cordis_mount') + .filter(event => event.data.name === 'cordis_try') .map(event => event.data.arguments) .join('\n---\n') const trace = events.map((event) => { @@ -115,13 +115,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ).toBe(true) }, 120_000) - it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { + it('composes two temporary Plugins through provide/inject, and stopping the provider parks the consumer', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.followup([{ type: 'text', - text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + text: 'Try TWO separate temporary Plugins with cordis_try. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' + 'a tool named shout_text with one required string parameter "text" whose execute returns ' @@ -144,16 +144,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup([{ type: 'text', text: 'Now stop ONLY the provider temporary Plugin (the one that provided shouter).' }]) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, dynamic table naming the missing service. + // dependent tool unregistered, temporary section naming the missing service. expect(ctx.get('shouter')).toBeUndefined() expect(ctx.tools.get('shout_text')).toBeUndefined() const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(after)).toContain('waiting for: shouter') }, 120_000) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 014ce74f7c..c6cc7e30fb 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -15,8 +15,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to add a plugin, cordis_unmount ' - + 'to clean one up. Follow the tool descriptions exactly and report results briefly.' + + 'cordis_inspect to look around, cordis_try to try a temporary Plugin, cordis_stop ' + + 'to stop one. Follow the tool descriptions exactly and report results briefly.' export async function cordisHarness(): Promise { const ctx = new Context() diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ef84d7309f..9504e21d86 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -248,25 +248,25 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { expect(ctx.tasks.list()).toEqual([]) }, 15_000) - it('uses cordis_mount DTO ids directly for active and pending mounts, then confirms removal', async () => { + it('uses cordis_try DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => { ctx = await typedCodeModeHarness() await ctx.plugin(ToolCordis) const value = completion(await runCode(ctx, ` - const active = await tools.cordis_mount({ + const active = await tools.cordis_try({ code: "return { name: 'active-code-mode-plugin', apply(ctx) {} }", }); - const pending = await tools.cordis_mount({ + const pending = await tools.cordis_try({ code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }", }); - const before = await tools.cordis_inspect({ what: 'dynamic' }); - const unmounted = await tools.cordis_unmount({ id: active.id }); - const after = await tools.cordis_inspect({ what: 'dynamic' }); - await tools.cordis_unmount({ id: pending.id }); + const before = await tools.cordis_inspect({ what: 'temporary' }); + const stopped = await tools.cordis_stop({ id: active.id }); + const after = await tools.cordis_inspect({ what: 'temporary' }); + await tools.cordis_stop({ id: pending.id }); return { active, pending, - unmounted, + stopped, beforeContainsId: before.includes(active.id), afterContainsId: after.includes(active.id), }; @@ -287,7 +287,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { provides: [], waitingFor: ['missing-code-mode-service'], }, - unmounted: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, + stopped: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, beforeContainsId: true, afterContainsId: false, }) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json index 41072a211a..3a6418d41c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } + { "op": "prompt", "text": "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } ] } diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 6cae860e36..1f777928eb 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index c00a4119c7..d93afe1808 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 9d2b188a45..fbdd9dc6f9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,29 +1,29 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}} -{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785037378917,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785037378920,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -47,13 +47,13 @@ {"type":"step/end","seq":45,"time":1785037379529,"data":{"turn":1,"step":4}} {"type":"step/start","seq":46,"time":1785037379531,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":51,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785037379536,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785037379538,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 30dea5ebe8..503ea5b0be 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -1,28 +1,28 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -46,13 +46,13 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":45,"time":0,"data":{"turn":1,"step":4}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":46,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 99eaf6e4ee..9e2aa427d3 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index 9a80b08e9e..d2290421f2 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,26 +1,26 @@ {"type": "session", "version": 0, "id": "11111111-1111-4111-8111-111111111111", "createdAt": 1783950000000, "cwd": "/tmp/advanced-acp", "delegationDepth": 0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}}} {"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}} -{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} {"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} @@ -45,13 +45,13 @@ {"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a3739deefa..a4f6159eea 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=48 base=12 viewport=12 +terminal 100x36 buffer=normal length=50 base=14 viewport=14 lifecycle started=1 stopped=0 progress=inactive title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=45 +cursor hidden column=1 viewportRow=33 bufferRow=47 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -16,11 +16,11 @@ buffer 5| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " +6| "▌ Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; " style 0-0 fg=bright-blue -7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " +7| "▌ use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to " style 0-0 fg=bright-blue -8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " +8| "▌ a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then " style 0-0 fg=bright-blue 9| "▌ reply with exactly ADVANCED_ACP_OK. " style 0-0 fg=bright-blue @@ -29,78 +29,82 @@ buffer 11| 12| "▌ " style 0-0 fg=green -13| "▌ ✓ Mount plugin into live cordis runtime " +13| "▌ ✓ Try temporary Cordis Plugin " style 0-0 fg=green style 2-2 fg=green bold - style 3-40 bold -14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " + style 3-30 bold +14| "▌ Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH " style 0-0 fg=green -15| "▌ " +15| "▌ restarts). " style 0-0 fg=green -16| -17| "▌ " +16| "▌ " style 0-0 fg=green -18| "▌ ✓ Verify the dynamically mounted marker service " +17| +18| "▌ " + style 0-0 fg=green +19| "▌ ✓ Verify the temporary marker Plugin " style 0-0 fg=green style 2-2 fg=green bold - style 3-48 bold -19| "▌ ## dynamic " + style 3-37 bold +20| "▌ ## Temporary Plugins " style 0-0 fg=green -20| "▌ - dyn-1: snapshot-marker [active] " +21| "▌ - Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime:" style 0-0 fg=green -21| "▌ " +22| "▌ until stopped or DSH restarts " style 0-0 fg=green -22| 23| "▌ " style 0-0 fg=green -24| "▌ ✓ subagent " +24| +25| "▌ " + style 0-0 fg=green +26| "▌ ✓ subagent " style 0-0 fg=green style 2-2 fg=green bold style 3-11 bold -25| "▌ DIRECT_CHILD_OK " +27| "▌ DIRECT_CHILD_OK " style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| 28| "▌ " style 0-0 fg=green -29| "▌ ✓ workflow: advanced-acp-snapshot " +29| +30| "▌ " + style 0-0 fg=green +31| "▌ ✓ workflow: advanced-acp-snapshot " style 0-0 fg=green style 2-2 fg=green bold style 3-34 bold -30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " +32| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " style 0-0 fg=green -31| "▌ Return value: " +33| "▌ Return value: " style 0-0 fg=green -32| "▌ { " +34| "▌ { " style 0-0 fg=green -33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " +35| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " style 0-0 fg=green -34| "▌ } " +36| "▌ } " style 0-0 fg=green -35| "▌ " - style 0-0 fg=green -36| 37| "▌ " style 0-0 fg=green -38| "▌ ✓ Unmount dyn-1 " +38| +39| "▌ " + style 0-0 fg=green +40| "▌ ✓ Stop temporary Cordis Plugin dyn-1 " style 0-0 fg=green style 2-2 fg=green bold - style 3-16 bold -39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " + style 3-37 bold +41| "▌ Temporary Plugin dyn-1 was stopped and removed. " style 0-0 fg=green -40| "▌ " +42| "▌ " style 0-0 fg=green -41| -42| " Assistant " +43| +44| " Assistant " style 1-9 fg=bright-magenta bold -43| " ADVANCED_ACP_OK " -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse +45| " ADVANCED_ACP_OK " 46| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" +47| " " + style 1-1 inverse +48| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +49| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" style 0-90 dim style 93-99 dim diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..8eaba618b2 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -122,7 +122,7 @@ const SCENARIOS: Scenario[] = [ { name: 'cordis-dynamic-toolchain', composition: 'advanced', - expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'], + expectedTools: ['cordis_try', 'run_code', 'subagent', 'workflow', 'cordis_stop'], expectedEventCounts: { 'tool/code-dispatch': 1 }, childSessions: 2, recorded: false, diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index 1b70a52e70..53fe781704 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/README.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 -README.md: b3a70b07c57ae1a2e3dd975a64c840d90c5a84ad -README.zh.md: 5832310cfe14a299ac5998a28bcbbb500caf86b5 +# pnpm run verify-translation-pairing --write packages/cordis/README.md +README.md: bcd11230cdaaaf2893bcc64eadfe689855bc3553 +README.zh.md: c26424f08dced26aa60d0a21202449e1b3b16860 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index b3a70b07c5..bcd11230cd 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in | Package | Role | ctx key | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the runtime, evaluate model-written plugin code in a `node:vm` sandbox, and manage the dynamic mounts under one group fiber | registers on `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_try` / `cordis_stop` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index 5832310cfe..c26424f08d 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向模型、作用于 agent(智能体)自身所在实时 Cordis 运行时的工具:检查已加载插件与服务接口、挂载模型编写的插件,以及再次释放这些插件。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +面向模型、作用于 agent(智能体)所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并尝试或停止仅存于内存的临时 Plugin。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 | 包(package) | 角色 | ctx 键 | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取运行时、在 `node:vm` 沙箱中求值模型编写的插件代码,并在同一个分组 fiber 下管理动态挂载 | 注册到 `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_try`/`cordis_stop` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` | diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 23535e1b06..684fa672bf 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/README.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 -README.md: 022e25decad650e03aa621cfa2b3f33ccc8d9743 -README.zh.md: 99a79209a2a895ee8e11e3b7345a305da514ac1e +# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md +README.md: fda296817026556f235d42626e87fe1f361f2c36 +README.zh.md: b8e3c02ba43a7c366664f5964168798ee7356186 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 022e25deca..fda2968170 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -2,17 +2,19 @@ English | [中文](README.zh.md) -The self-referential cordis toolset: three model-facing tools over the live runtime the agent runs inside. Design home — sandbox semantics, mount lifecycle, cross-mount composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential Cordis toolset: three model-facing tools over the live runtime in the current DSH process. Design home — sandbox semantics, temporary-plugin lifecycle and composition, the generated API catalog, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## What it does -- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. -- `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-`. -- `cordis_unmount` — disposes one mount by id, returning only after quiescence. +- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_try` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. +- `cordis_try` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-`. +- `cordis_stop` — stops one `dyn-` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins. Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). -Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native renderers preserve the existing prose, so programs can use `mounted.id` while ordinary function calling still sees `mounted dyn-1 (...)`. +Canonical successes are the inspection string, try `{ id, pluginName, state, provides, waitingFor }`, and stop `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until stopped or DSH restarts; stop confirms that it was stopped and removed. + +Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_stop`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ## Trust stance @@ -22,7 +24,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab | Field | Default | Meaning | |---|---|---| -| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of mount-code evaluation; an async body escapes it | +| `vmTimeoutMs` | `5000` | Bound on the SYNCHRONOUS portion of temporary-Plugin code evaluation; an async body escapes it | ## The generated API catalog @@ -30,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab ## Rendering -All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the mount code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. +All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_try` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. ## Export shape @@ -42,7 +44,7 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau #### What the model sees -The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. +The conversation model sees the generated [`cordis_inspect`, `cordis_try`, and `cordis_stop` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. #### Token effect @@ -56,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha #### What the model sees -Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. +Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until stopped or DSH restart. The empty state explains that `cordis_try` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Try returns `Temporary Plugin is running (...)` or `Temporary Plugin is pending (...)`; stop returns `Temporary Plugin was stopped and removed.` The submitted program remains in assistant tool-call history. #### Token effect @@ -66,19 +68,19 @@ Inspect output and mount code are data-dependent and resent until compaction; li Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. -### Later requests after a mount +### Later requests after cordis_try #### What the model sees -A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. +A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` removes those contributions after quiescence. #### Token effect -Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. +Indirect token impact equals the temporary Plugin's contributions and lasts only for its process-local lifetime. #### KV Cache effect -Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable. +Trying or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable. ## Known Limitations and Deferred Work diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 99a79209a2..b8e3c02ba4 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -2,17 +2,19 @@ [English](README.md) | 中文 -自引用 cordis 工具集:三个面向模型的工具,操作 agent 所处的存活运行时。设计归属(沙箱语义、挂载生命周期、跨挂载组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的存活运行时。设计归属(沙箱语义、临时 Plugin 生命周期与组合、生成的 API 目录、既定决策)见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 功能 -- `cordis_inspect`:运行时的只读报告,包括服务、已加载插件列表、已注册工具、动态挂载表,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 -- `cordis_mount`:在 `node:vm` 沙箱中求值模型编写的 JavaScript(一个 async 函数的主体);代码必须 `return` 一个 cordis 插件,系统将其挂载在 `cordis-dynamic` 分组 fiber 下,并以 `dyn-` 跟踪。 -- `cordis_unmount`:按 id 释放一项挂载,只在完全停稳后返回。 +- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_try` 临时 Plugin 子集,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 +- `cordis_try`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-` 跟踪、仅存于内存的临时 Plugin。 +- `cordis_stop`:停止一个 `dyn-` 临时 Plugin,并只在其自有效果完全停稳后返回;它不能删除 Loader、配置或已安装的 Plugin。 精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。 -规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 保留现有文本,因此程序可以使用 `mounted.id`,普通 Function Calling 仍会看到 `mounted dyn-1 (...)`。 +规范成功值分别为检查字符串、尝试 `{ id, pluginName, state, provides, waitingFor }`,以及停止 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被停止或 DSH 重启;停止结果确认它已停止并移除。 + +临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session,但会在 `cordis_stop`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 ## 信任立场 @@ -22,7 +24,7 @@ | 字段 | 默认值 | 含义 | |---|---|---| -| `vmTimeoutMs` | `5000` | 挂载代码求值中同步部分的边界;async 主体可逃出该边界 | +| `vmTimeoutMs` | `5000` | 临时 Plugin 代码求值中同步部分的边界;async 主体可逃出该边界 | ## 生成的 API 目录 @@ -30,7 +32,7 @@ ## 渲染 -三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带挂载代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 +三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_try` 以 `rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 ## 导出形状 @@ -42,7 +44,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### 模型看到的内容 -该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 +该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_try` 和 `cordis_stop` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 #### Token 影响 @@ -56,7 +58,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### 模型看到的内容 -检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `mounted (plugin "", state: )`,并可在右括号前插入 ` — waiting for service(s): (activates when provided)`。卸载返回 `unmounted (plugin "")`;未知 id 会变成 `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`。提交的挂载程序保留在 assistant 工具调用历史中。 +检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 running/pending 状态、提供与等待的服务,以及持续至停止或 DSH 重启的生命周期;空状态说明 `cordis_try` Plugin 会在重启时消失。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。尝试结果为 `Temporary Plugin is running (...)` 或 `Temporary Plugin is pending (...)`;停止结果为 `Temporary Plugin was stopped and removed.`。提交的程序保留在 assistant 工具调用历史中。 #### Token 影响 @@ -66,19 +68,19 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 -### 挂载后的后续请求 +### cordis_try 后的后续请求 #### 模型看到的内容 -已挂载插件可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;卸载会在完全停稳后移除这些贡献。 +临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 会在完全停稳后移除这些贡献。 #### Token 影响 -间接 token 影响等于已挂载插件的贡献,且只在挂载生命周期内持续。 +间接 token 影响等于临时 Plugin 的贡献,且只在其进程内生命周期内持续。 #### KV Cache 影响 -挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;挂载集合不变时,前缀保持稳定。 +尝试或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。 ## 已知限制与暂缓事项 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index b9bc992ad8..96043373ff 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context { if (ctx.get(prop) !== undefined) { throw new Error( `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, ` - + 'so cordis parks this mount if the provider is later unmounted.', + + 'so cordis parks this temporary Plugin if the provider later stops.', ) } throw new Error( diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 15fd735468..47b2755d34 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -1,6 +1,6 @@ /** - * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin - * under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, + * Self-referential runtime tools: inspect live services/plugins/tools, try a returned temporary + * plugin under an owned dynamic fiber, and stop it to quiescence. Registrations are fiber effects, * so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent * accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real * runtime. Named exports preserve loader injection metadata. @@ -15,7 +15,7 @@ import { isPlugin, pluginName } from './guard.ts' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts' import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' -import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' +import { presentInspectCall, presentStopCall, presentTryCall } from './present.ts' import { createSandbox, evaluateMountCode } from './sandbox.ts' export const name = 'tool-cordis' @@ -40,8 +40,8 @@ export const Config: z = z.object({ type ResolvedConfig = Required /** - * Mount the three cordis tools on `ctx.tools` and create the `cordis-dynamic` - * group fiber every dynamic mount hangs under. + * Register the three cordis tools and own every temporary plugin under one + * `cordis-dynamic` group fiber. * @param ctx - the plugin context (`tools` injected). * @param config - the schemastery-resolved {@link Config}. */ @@ -56,19 +56,21 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'cordis_inspect', description: - 'Inspect the live cordis runtime that is running THIS agent. Read-only. ' + 'Inspect the live Cordis runtime in the current DSH process. Read-only. ' + 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), ' - + '`plugins` (a flat list of the loaded plugins with their lifecycle states), ' + + '`plugins` (all live plugin fibers with their lifecycle states), ' + '`tools` (the model-facing tools currently registered, i.e. what you can call), ' - + '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), ' + + '`temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), ' + '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), ' + '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). ' - + 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` ' + + 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. ' + + 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. ' + + 'With `what:"api"` or `what:"events"`, pass an exact `name` ' + 'to narrow to one service/event and include its original source JSDoc.', parameters: { what: { type: 'string', - enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'], + enum: ['services', 'plugins', 'tools', 'temporary', 'api', 'events'], description: 'Limit the report to one section. Omit for all sections.', }, name: { @@ -84,19 +86,19 @@ export function apply(ctx: Context, config: Config): void { if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { throw new Error('name is valid only with what:"api" or what:"events"') } - const sections: [heading: string, body: () => string[]][] = [ - ['services', () => describeServices(ctx)], - ['plugins', () => describePlugins(ctx)], + const sections: [key: string, heading: string, body: () => string[]][] = [ + ['services', 'services', () => describeServices(ctx)], + ['plugins', 'plugins', () => describePlugins(ctx)], // The calling agent's view: scoped/shadowed tools included, restricted // globals absent — "what you can call", not the global registry. - ['tools', () => describeTools(ctx, exec.agent)], - ['dynamic', () => describeDynamic(ctx, mounts)], - ['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)], - ['events', () => describeEvents(EVENT_API, args.name)], + ['tools', 'tools', () => describeTools(ctx, exec.agent)], + ['temporary', 'Temporary Plugins', () => describeDynamic(ctx, mounts)], + ['api', 'api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)], + ['events', 'events', () => describeEvents(EVENT_API, args.name)], ] - const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading) + const selected = sections.filter(([key]) => args.what === undefined || args.what === key) const text = selected - .map(([heading, body]) => `## ${heading}\n${body().join('\n')}`) + .map(([, heading, body]) => `## ${heading}\n${body().join('\n')}`) .join('\n\n') return Promise.resolve(text) }, @@ -104,10 +106,15 @@ export function apply(ctx: Context, config: Config): void { })) ctx.tools.register(defineTool({ - name: 'cordis_mount', + name: 'cordis_try', description: - 'Mount a NEW cordis plugin into the live runtime that is running THIS agent ' - + '(self-modification). `code` runs as the body of an async JavaScript function ' + 'Try a temporary Cordis Plugin in the current DSH process. ' + + 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. ' + + 'It remains active across later turns until cordis_stop, toolset unload, or DSH restart. ' + + 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. ' + + 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ' + + 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. ' + + '`code` runs now as the body of an async JavaScript function ' + 'in an isolated sandbox and MUST `return` a plugin. Two forms: ' + 'FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register ' + 'tools, listen to events, and provide services, but reaching ANY service (e.g. ' @@ -116,7 +123,7 @@ export function apply(ctx: Context, config: Config): void { + '— declares dependencies, and cordis activates the plugin only after the ' + 'services exist; PREFER this form. You may reach ONLY the services you list in ' + 'inject: an undeclared service throws even if it exists, because an undeclared ' - + 'dependency would not be cleaned up if its provider is unmounted. ' + + 'dependency would not be cleaned up if its provider stops. ' + 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists ' + 'method signatures AND the type shapes of their arguments/returns (do not guess a ' + 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). ' @@ -131,10 +138,10 @@ export function apply(ctx: Context, config: Config): void { + 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A ' + 'tool\'s `execute` MUST return the lossless JSON value declared by `output.schema`; ' + '`output.render(args, value)` separately returns Native/model content blocks. ' - + 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and ' + + 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and ' + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending ' - + 'until the provider exists and returns to pending when the provider is unmounted. ' - + 'Everything registered inside `apply` is cleaned up automatically on unmount. ' + + 'until the provider exists and returns to pending when the provider stops. ' + + 'Everything registered inside `apply` is cleaned up automatically by cordis_stop. ' + 'Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness ' + 'terminal), `harness.defineTool`, `harness.registerTool`, ' + '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. ' @@ -143,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { + 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for ' + 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, ' + 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, ' - + 'auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. ' + + 'auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. ' + 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). ' + 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a ' + 'trailing `next` callback which MUST be called — returning without `next()` ' @@ -159,7 +166,7 @@ export function apply(ctx: Context, config: Config): void { code: { type: 'string', required: true, - description: 'Body of an async JS function; must `return` the plugin to mount.', + description: 'JavaScript body returning a temporary Plugin; evaluated now and saved nowhere.', }, }, output: { @@ -179,12 +186,12 @@ export function apply(ctx: Context, config: Config): void { }, }, render: (_args, value) => { - const note = value.waitingFor.length > 0 - ? ` — waiting for service(s): ${value.waitingFor.join(', ')} (activates when provided)` - : '' + const status = value.waitingFor.length > 0 + ? `is pending (plugin "${value.pluginName}"; missing services: ${value.waitingFor.join(', ')}` + : `is running (plugin "${value.pluginName}"` return [{ type: 'text', - text: `mounted ${value.id} (plugin "${value.pluginName}", state: ${value.state}${note})`, + text: `Temporary Plugin ${value.id} ${status}; available until stopped or DSH restarts).`, }] }, }, @@ -195,13 +202,13 @@ export function apply(ctx: Context, config: Config): void { if (!isPlugin(evaluated)) { if (evaluated === undefined) { throw new Error( - 'mount code returned `undefined` — did you forget `return`?\n' + 'temporary Plugin code returned `undefined` — did you forget `return`?\n' + ' ✓ return (ctx) => { … }\n' + ' ✓ return { name: \'…\', inject: […], apply(ctx) { … } }', ) } throw new Error( - 'mount code must `return` a plugin: a function, or an object with an `apply(ctx)` method', + 'temporary Plugin code must `return` a Plugin: a function, or an object with an `apply(ctx)` method', ) } const fiber = await mountDynamic(group, evaluated) @@ -219,21 +226,19 @@ export function apply(ctx: Context, config: Config): void { waitingFor: missing, } }, - presentCall: presentMountCall, + presentCall: presentTryCall, })) ctx.tools.register(defineTool({ - name: 'cordis_unmount', + name: 'cordis_stop', description: - 'Dispose a plugin previously mounted with cordis_mount, by id. All its ' - + 'registrations (event listeners, tools, services) are cleaned up through ' - + 'the cordis effect lifecycle. Returns only after disposal has fully ' - + 'completed (quiescence, not just a request to stop).', + 'Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. ' + + 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.', parameters: { id: { type: 'string', required: true, - description: 'The dynamic mount id returned by cordis_mount (e.g. "dyn-1").', + description: 'The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart.', }, }, output: { @@ -245,17 +250,17 @@ export function apply(ctx: Context, config: Config): void { pluginName: { type: 'string', required: true }, }, }, - render: (_args, value) => [{ type: 'text', text: `unmounted ${value.id} (plugin "${value.pluginName}")` }], + render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was stopped and removed.` }], }, async execute(args) { const mount = mounts.get(args.id) if (!mount) { - throw new Error(`no dynamic plugin with id "${args.id}" (list mounts with cordis_inspect what:"dynamic")`) + throw new Error(`no temporary Plugin with id "${args.id}" (list them with cordis_inspect what:"temporary")`) } await mount.fiber.dispose() mounts.delete(args.id) return { id: args.id, pluginName: mount.pluginName } }, - presentCall: presentUnmountCall, + presentCall: presentStopCall, })) } diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index cdcad29699..b56546e512 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -1,6 +1,6 @@ /** * Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat - * plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits), + * plugin list, the registered tools, the temporary-plugin table (with per-plugin provides/waits), * and the catalog-backed `api` / `events` sections. Exact-name lookups add the * original source JSDoc without inflating the default reports. * @module @deepseek-ai/dsh-tool-cordis/inspect @@ -63,8 +63,8 @@ export function describeServices(ctx: Context): string[] { /** * The `plugins` section: a flat list of every fiber the registry knows, one * line per fiber with its lifecycle state, sorted by plugin name (a plugin - * mounted more than once repeats — one line per instance). Dynamic mounts are - * listed like any other plugin; their ids live in the `dynamic` section. + * mounted more than once repeats — one line per instance). Temporary plugins are + * listed like any other plugin; their ids live in the `temporary` section. * @param ctx - the runtime whose registry is enumerated. * @returns one line per loaded plugin fiber. */ @@ -91,7 +91,7 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { } /** - * The `dynamic` section: one line per mount with id, plugin name, lifecycle + * The `temporary` section: one line per temporary plugin with id, plugin name, lifecycle * state, the services its subtree provides, and — for a pending mount — the * services it waits for. * @param ctx - the runtime the mounts live in. @@ -99,13 +99,14 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { * @returns one line per mount, or a single placeholder line when none exist. */ export function describeDynamic(ctx: Context, mounts: ReadonlyMap): string[] { - if (mounts.size === 0) return ['(no dynamic plugins mounted)'] + if (mounts.size === 0) { + return ['No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.'] + } return [...mounts].map(([id, mount]) => { const provides = providedServices(ctx, mount.fiber) const waiting = missingServices(ctx, mount.fiber) - const providesNote = provides.length > 0 ? ` — provides: ${provides.join(', ')}` : '' - const waitingNote = waiting.length > 0 ? ` — waiting for: ${waiting.join(', ')}` : '' - return `- ${id}: ${mount.pluginName} [${STATE_LABELS[mount.fiber.state]}]${providesNote}${waitingNote}` + const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state] + return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until stopped or DSH restarts` }) } diff --git a/packages/cordis/tool-cordis/src/mount.ts b/packages/cordis/tool-cordis/src/mount.ts index fbed4cc161..e18e5c07ed 100644 --- a/packages/cordis/tool-cordis/src/mount.ts +++ b/packages/cordis/tool-cordis/src/mount.ts @@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise // while the old mount still holds the name — teach the replace recipe. if (message.includes('already registered')) { throw new Error( - `${message} — to REPLACE something an earlier mount registered, first cordis_unmount that mount's id ` - + '(find it with cordis_inspect what:"dynamic"), then mount the new version.', + `${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_stop that Plugin's id ` + + '(find it with cordis_inspect what:"temporary"), then try the new version.', ) } throw error instanceof Error ? error : new Error(message) diff --git a/packages/cordis/tool-cordis/src/present.ts b/packages/cordis/tool-cordis/src/present.ts index 2f824003df..35796378ca 100644 --- a/packages/cordis/tool-cordis/src/present.ts +++ b/packages/cordis/tool-cordis/src/present.ts @@ -25,28 +25,28 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene } /** - * The `cordis_mount` call card: an execute carrying the mount code as raw input. + * The `cordis_try` call card: an execute carrying the temporary-plugin code as raw input. * @param args - the validated call arguments. * @returns the generic call card. */ -export function presentMountCall(args: { code: string }): GenericCallView { +export function presentTryCall(args: { code: string }): GenericCallView { return { card: 'generic', kind: 'execute', - title: 'Mount plugin into live cordis runtime', + title: 'Try temporary Cordis Plugin', rawInput: { code: args.code }, } } /** - * The `cordis_unmount` call card: a delete, titled with the mount id. + * The `cordis_stop` call card: a delete, titled with the temporary-plugin id. * @param args - the validated call arguments. * @returns the generic call card. */ -export function presentUnmountCall(args: { id: string }): GenericCallView { +export function presentStopCall(args: { id: string }): GenericCallView { return { card: 'generic', kind: 'delete', - title: `Unmount ${args.id}`, + title: `Stop temporary Cordis Plugin ${args.id}`, } } diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 995881902e..5fc3be98d6 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -1,5 +1,5 @@ /** - * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a + * The `node:vm` sandbox `cordis_try` code evaluates in: a fresh realm whose globals are a * tagged write-through console, the `harness` registration helpers, the encoding primitives a * bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately * withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`, @@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void { const TIMER_REDIRECT = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin ' - + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically on unmount.' + + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when stopped.' /** * The callable Node APIs the sandbox deliberately disables, each mapped to the @@ -80,14 +80,14 @@ function nodeApiTraps(): Record never> { const traps: Record never> = {} for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) { traps[name] = () => { - throw new Error(`${name} is not available in the mount sandbox — ${redirect}`) + throw new Error(`${name} is not available in the temporary Plugin sandbox — ${redirect}`) } } return traps } /** - * Build the vm context one `cordis_mount` call evaluates in: the tagged + * Build the vm context one `cordis_try` call evaluates in: the tagged * console, the `harness` registration helpers, the encoding primitives, the * Node-API traps, and the dual-realm `instanceof` patch, already * `createContext`-ed. @@ -163,14 +163,14 @@ export async function evaluateMountCode(sandbox: object, code: string, id: strin const offendingLine = context.split('\n')[1] ?? '' if (/\bas\b/.test(offendingLine)) { throw new Error( - `mount code failed to parse:\n${context}\n` + `temporary Plugin code failed to parse:\n${context}\n` + 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n' + ' ✗ { type: \'text\' as const, text: x }\n' + ' ✓ { type: \'text\', text: x }', ) } throw new Error( - `mount code failed to parse:\n${context}\n` + `temporary Plugin code failed to parse:\n${context}\n` + 'Note: `code` runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). ' + 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; ' + 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.', diff --git a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts index b2e515b4c8..2772b216e0 100644 --- a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts @@ -11,12 +11,12 @@ import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } describe('cross-mount provide/inject', () => { it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => { const ctx = await setup() - const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) - expect(text(provider)).toContain('state: active') + const provider = await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + expect(text(provider)).toContain('is running') - const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) + const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: active') + expect(text(consumer)).toContain('is running') // The vm-realm service value is callable across mounts, and the result // normalizes into the host realm like any dynamic tool result. @@ -27,62 +27,62 @@ describe('cross-mount provide/inject', () => { it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => { const ctx = await setup() - const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) + const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: pending') - expect(text(consumer)).toContain('waiting for service(s): greeter') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('waiting for: greeter') + expect(text(consumer)).toContain('is pending') + expect(text(consumer)).toContain('missing services: greeter') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter') expect(ctx.tools.get('greet')).toBeUndefined() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) expect(ctx.tools.get('greet')).toBeDefined() expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late') }) it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 expect(ctx.tools.get('greet')).toBeDefined() - const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) expect(ctx.tools.get('greet')).toBeUndefined() - const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(report).toContain('dyn-2: greeter-consumer [pending] — waiting for: greeter') + const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until stopped or DSH restarts') }) it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 - await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(ctx.tools.get('greet')).toBeUndefined() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3 + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-3 expect(ctx.tools.get('greet')).toBeDefined() expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-2: greeter-consumer [active]') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]') }) it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) - const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + const duplicate = await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) expect(duplicate.isError).toBe(true) expect(text(duplicate)).toContain('has been registered') - const report = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(report).toContain('dyn-1: greeter-provider') + const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(report).toContain('Temporary Plugin dyn-1: greeter-provider') expect(report).not.toContain('dyn-2') }) it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) - await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) - const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'dynamic' })) - expect(dynamic).toContain('dyn-1: greeter-provider [active] — provides: greeter') + const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) + expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until stopped or DSH restarts') const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) expect(services).toContain('- greeter (provided by greeter-provider)') @@ -93,7 +93,7 @@ describe('cross-mount provide/inject', () => { it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => { const ctx = await setup() - const provider = await call(ctx, 'cordis_mount', { + const provider = await call(ctx, 'cordis_try', { code: ` return { name: 'answer-provider', @@ -106,7 +106,7 @@ describe('cross-mount provide/inject', () => { }) expect(provider.isError).toBe(false) - const consumer = await call(ctx, 'cordis_mount', { + const consumer = await call(ctx, 'cordis_try', { code: ` return { name: 'answer-consumer', @@ -126,19 +126,19 @@ describe('cross-mount provide/inject', () => { `, }) expect(consumer.isError).toBe(false) - expect(text(consumer)).toContain('state: active') + expect(text(consumer)).toContain('is running') expect(text(await call(ctx, 'answer', {}))).toBe('42/42/null') }) it('unmounting the consumer leaves the provider and its service intact', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 - await call(ctx, 'cordis_unmount', { id: 'dyn-2' }) + await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_stop', { id: 'dyn-2' }) expect(ctx.tools.get('greet')).toBeUndefined() const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) expect(services).toContain('- greeter (provided by greeter-provider)') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('dyn-1: greeter-provider [active]') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-1: greeter-provider [running]') }) }) diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 18b4d04df7..9406478f4e 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -18,7 +18,7 @@ describe('cordis_inspect', () => { const report = text(result) if (result.isError) throw new Error('expected cordis_inspect success') expect(result.value).toBe(report) - for (const heading of ['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) { + for (const heading of ['services', 'plugins', 'tools', 'Temporary Plugins', 'api', 'events']) { expect(report).toContain(`## ${heading}`) } // The services section sees the real providers; the plugins list shows @@ -27,8 +27,8 @@ describe('cordis_inspect', () => { expect(report).toContain('- tools (provided by ToolRegistry)') expect(report).toContain('- tool-cordis [active]') expect(report).toContain('- cordis-dynamic [active]') - expect(report).toContain('- cordis_mount') - expect(report).toContain('(no dynamic plugins mounted)') + expect(report).toContain('- cordis_try') + expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.') }) it('limits the report to one section via `what`', async () => { @@ -40,11 +40,12 @@ describe('cordis_inspect', () => { expect(report).not.toContain('## plugins') }) - it('shows a mount in the dynamic section and in the flat plugins list', async () => { + it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + await call(ctx, 'cordis_try', { code: LISTENER_CODE }) const report = text(await call(ctx, 'cordis_inspect', {})) - expect(report).toContain('- dyn-1: change-logger [active]') + expect(report).toContain('## Temporary Plugins') + expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts') expect(report).toContain('- change-logger [active]') }) diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 7f917e5b3c..3f983a5dff 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { REVERSE_TOOL_CODE } from './helpers.ts' +import { call, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** * Full-loop integration: a scripted mock model mounts a plugin that registers @@ -39,9 +40,9 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { describe('cordis tools through the agent loop', () => { it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => { const adapter = new MockAdapter([ - toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'), + toolCallResponse('call-1', 'cordis_try', { code: REVERSE_TOOL_CODE }, 'Extending myself.'), toolCallResponse('call-2', 'reverse_text', { text: 'harness' }), - toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }), + toolCallResponse('call-3', 'cordis_stop', { id: 'dyn-1' }), textResponse('Done.'), ]) const ctx = await harness(adapter) @@ -52,7 +53,7 @@ describe('cordis tools through the agent loop', () => { const log = agent.session.events const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name) - expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount']) + expect(calls).toEqual(['cordis_try', 'reverse_text', 'cordis_stop']) const results = log.filter(event => event.type === 'tool/result') expect(results.map(event => event.data.isError)).toEqual([false, false, false]) @@ -65,4 +66,36 @@ describe('cordis tools through the agent loop', () => { // After the unmount the self-made tool is gone from the registry. expect(ctx.tools.get('reverse_text')).toBeUndefined() }) + + it('keeps a temporary Plugin across turns, stops it, and does not restore it in a new runtime', async () => { + const adapter = new MockAdapter([ + toolCallResponse('try-1', 'cordis_try', { code: 'return { name: \'turn-marker\', apply() {} }' }), + toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }), + textResponse('Turn one complete.'), + toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }), + toolCallResponse('stop-1', 'cordis_stop', { id: 'dyn-1' }), + toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }), + textResponse('Turn two complete.'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' }) + + agent.followup([{ type: 'text', text: 'Try the marker and inspect it.' }]) + await waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, stop it, then inspect again.' }]) + await waitForIdle(ctx, agent) + + const resultText = new Map( + agent.session.events + .filter(event => event.type === 'tool/result') + .map(event => [event.data.callId, event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')]), + ) + expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') + expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') + expect(resultText.get(CallId('stop-1'))).toBe('Temporary Plugin dyn-1 was stopped and removed.') + expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.') + + const restarted = await setup() + expect(text(await call(restarted, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') + }) }) diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index a354de0b37..af5d51ffd3 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -5,7 +5,7 @@ import { syntaxErrorContext } from '../src/sandbox.ts' import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** - * The `cordis_mount` success/failure family: real plugins land on a genuine + * The `cordis_try` success/failure family: real plugins land on a genuine * cordis fiber tree, their registrations are observable through the real * registry/event bus, and every rejection path teaches the fix. */ @@ -14,7 +14,7 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('cordis_mount', () => { +describe('cordis_try', () => { it.each([ [42, 'options must be an object'], [{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'], @@ -47,9 +47,9 @@ describe('cordis_mount', () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + const result = await call(ctx, 'cordis_try', { code: LISTENER_CODE }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected cordis_mount success') + if (result.isError) throw new Error('expected cordis_try success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger', @@ -57,7 +57,7 @@ describe('cordis_mount', () => { provides: [], waitingFor: [], }) - expect(text(result)).toContain('mounted dyn-1 (plugin "change-logger", state: active)') + expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until stopped or DSH restarts).') // Fire a REAL tools/change by registering a tool; the mounted listener logs. ctx.tools.register(dummyTool('trigger_a')) @@ -66,16 +66,16 @@ describe('cordis_mount', () => { it('mounts a bare-function plugin as , and a named function under its name', async () => { const ctx = await setup() - const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' }) + const anonymous = await call(ctx, 'cordis_try', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' }) expect(anonymous.isError).toBe(false) expect(text(anonymous)).toContain('plugin ""') - const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' }) + const named = await call(ctx, 'cordis_try', { code: 'return function watcher(ctx) {}' }) expect(text(named)).toContain('plugin "watcher"') }) it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) + const result = await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) expect(result.isError).toBe(false) expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text') @@ -89,14 +89,14 @@ describe('cordis_mount', () => { it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => { // VM-realm objects fail the session prototype-identity check; normalize them into host JSON. const ctx = await setup() - await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) const reversed = await call(ctx, 'reverse_text', { text: 'harness' }) expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true) }) it('projects presentation metadata from a dynamic canonical value', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'meta-return', @@ -136,7 +136,7 @@ describe('cordis_mount', () => { ['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'], ])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => { const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'bad-return', @@ -162,7 +162,7 @@ describe('cordis_mount', () => { it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'huge-return', @@ -189,7 +189,7 @@ describe('cordis_mount', () => { // These common JSON-Schema spellings each have one DSL meaning, so normalize rather than // consume another model turn with a rejection. const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'json-schema-tool', @@ -245,7 +245,7 @@ describe('cordis_mount', () => { // On an object PROPERTY, a JSON-Schema-style `required` array names the // required children — the nested unwrap converts it just like the top level. const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'nested-json-schema', @@ -276,7 +276,7 @@ describe('cordis_mount', () => { it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'unified-schema', @@ -324,7 +324,7 @@ describe('cordis_mount', () => { it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => { const ctx = await setup() const depth = 5_000 - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'deep-unified-schema', @@ -377,7 +377,7 @@ describe('cordis_mount', () => { it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'raw-unified-schema', @@ -467,7 +467,7 @@ describe('cordis_mount', () => { ['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'], ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'bad-schema', @@ -508,7 +508,7 @@ describe('cordis_mount', () => { ], ])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'circular-schema', @@ -531,7 +531,7 @@ describe('cordis_mount', () => { it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'proto-schema', @@ -566,7 +566,7 @@ describe('cordis_mount', () => { it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'nested-schema', @@ -593,7 +593,7 @@ describe('cordis_mount', () => { it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'raw-register', @@ -618,7 +618,7 @@ describe('cordis_mount', () => { it('guards the registry reached through ctx.get(\'tools\') identically', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'raw-register-get', @@ -636,13 +636,13 @@ describe('cordis_mount', () => { it('passes non-register registry members through the guard with correct binding', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'schema-reader', inject: ['tools'], apply(ctx) { - console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount')) + console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_try')) }, } `, @@ -653,11 +653,11 @@ describe('cordis_mount', () => { it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }', }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected pending cordis_mount success') + if (result.isError) throw new Error('expected pending cordis_try success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'waiter', @@ -665,64 +665,63 @@ describe('cordis_mount', () => { provides: [], waitingFor: ['no-such-service'], }) - expect(text(result)).toContain('state: pending') - expect(text(result)).toContain('waiting for service(s): no-such-service') + expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until stopped or DSH restarts).') // Unmounting a pending mount works like any other. - const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) }) it('rejects code that throws, leaving nothing mounted', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' }) + const result = await call(ctx, 'cordis_try', { code: 'throw new Error(\'boom in sandbox\')' }) expect(result.isError).toBe(true) expect(text(result)).toContain('boom in sandbox') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => { const ctx = await setup() - const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' }) + const primitive = await call(ctx, 'cordis_try', { code: 'throw \'plain-string-throw\'' }) expect(primitive.isError).toBe(true) expect(text(primitive)).toContain('plain-string-throw') - const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' }) + const nullish = await call(ctx, 'cordis_try', { code: 'throw null' }) expect(nullish.isError).toBe(true) }) it('rejects code that does not return a plugin', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: 'return 42' }) + const result = await call(ctx, 'cordis_try', { code: 'return 42' }) expect(result.isError).toBe(true) - expect(text(result)).toContain('must `return` a plugin') + expect(text(result)).toContain('must `return` a Plugin') }) it('answers a missing return with the two valid plugin forms', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' }) + const result = await call(ctx, 'cordis_try', { code: 'const plugin = (ctx) => {}' }) expect(result.isError).toBe(true) expect(text(result)).toContain('did you forget `return`?') }) it('disposes a plugin whose apply throws, and reports the error', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }', }) expect(result.isError).toBe(true) expect(text(result)).toContain('apply exploded') - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'usurper', inject: ['tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ - name: 'cordis_mount', + name: 'cordis_try', description: 'dup', parameters: {}, ${CONTENT_OUTPUT_CODE} @@ -734,15 +733,15 @@ describe('cordis_mount', () => { }) expect(result.isError).toBe(true) expect(text(result)).toContain('already registered') - expect(text(result)).toContain('first cordis_unmount') - // The original cordis_mount still dispatches — the failed fiber is gone. - const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + expect(text(result)).toContain('first cordis_stop') + // The original cordis_try still dispatches — the failed fiber is gone. + const retry = await call(ctx, 'cordis_try', { code: LISTENER_CODE }) expect(retry.isError).toBe(false) }) it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` globalThis.__cordis_tool_leak = 'leaked' return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} } @@ -754,22 +753,22 @@ describe('cordis_mount', () => { }) it.each([ - ['require(\'fs\')', 'require is not available in the mount sandbox', 'inject: [\'fs\']'], - ['setTimeout(() => {}, 5)', 'setTimeout is not available in the mount sandbox', 'ctx.setTimeout'], - ['fetch(\'https://example.com\')', 'fetch is not available in the mount sandbox', 'ctx.web'], + ['require(\'fs\')', 'require is not available in the temporary Plugin sandbox', 'inject: [\'fs\']'], + ['setTimeout(() => {}, 5)', 'setTimeout is not available in the temporary Plugin sandbox', 'ctx.setTimeout'], + ['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'], ])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` }) + const result = await call(ctx, 'cordis_try', { code: `${invocation}\nreturn (ctx) => {}` }) expect(result.isError).toBe(true) expect(text(result)).toContain(trapMessage) expect(text(result)).toContain(redirect) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'ticker', @@ -781,7 +780,7 @@ describe('cordis_mount', () => { `, }) expect(result.isError).toBe(false) - expect(text(result)).toContain('state: active') + expect(text(result)).toContain('is running') await new Promise(resolve => setTimeout(resolve, 50)) expect(log).toHaveBeenCalledWith('[cordis:dyn-1]', 'tick') }) @@ -790,7 +789,7 @@ describe('cordis_mount', () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` console.warn('warned') console.error('errored') @@ -808,7 +807,7 @@ describe('cordis_mount', () => { it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'ts\' as const, apply(ctx) {} }', }) expect(result.isError).toBe(true) @@ -820,7 +819,7 @@ describe('cordis_mount', () => { // The canonical model mistake: closing the returned object with `});` as // if it were a callback argument. The word "as" in a STRING elsewhere must // not trigger the TypeScript hint — the heuristic reads the failing line. - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});', }) expect(result.isError).toBe(true) @@ -843,7 +842,7 @@ describe('cordis_mount', () => { it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' }) + const result = await call(ctx, 'cordis_try', { code: 'throw new SyntaxError(\'user-crafted\')' }) expect(result.isError).toBe(true) expect(text(result)).toContain('failed to parse') expect(text(result)).toContain('user-crafted') @@ -851,10 +850,10 @@ describe('cordis_mount', () => { it('honors the configured vmTimeoutMs for the synchronous portion', async () => { const ctx = await setup({ vmTimeoutMs: 50 }) - const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' }) + const result = await call(ctx, 'cordis_try', { code: 'while (true) {}' }) expect(result.isError).toBe(true) expect(text(result)).toMatch(/timed? ?out/i) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => { @@ -862,7 +861,7 @@ describe('cordis_mount', () => { // Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently // false. const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'probe-instanceof', diff --git a/packages/cordis/tool-cordis/tests/present.spec.ts b/packages/cordis/tool-cordis/tests/present.spec.ts index ed45fd1518..26b9360672 100644 --- a/packages/cordis/tool-cordis/tests/present.spec.ts +++ b/packages/cordis/tool-cordis/tests/present.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts' +import { presentInspectCall, presentTryCall, presentStopCall } from '../src/present.ts' import { setup } from './helpers.ts' /** @@ -18,17 +18,17 @@ describe('presenters', () => { }) }) - it('cordis_mount renders a generic execute card carrying the code as raw input', () => { - expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({ + it('cordis_try renders a generic execute card carrying the code as raw input', () => { + expect(presentTryCall({ code: 'return (ctx) => {}' })).toEqual({ card: 'generic', kind: 'execute', - title: 'Mount plugin into live cordis runtime', + title: 'Try temporary Cordis Plugin', rawInput: { code: 'return (ctx) => {}' }, }) }) - it('cordis_unmount renders a generic delete card titled with the id', () => { - expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount dyn-1' }) + it('cordis_stop renders a generic delete card titled with the id', () => { + expect(presentStopCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Stop temporary Cordis Plugin dyn-1' }) }) it('is wired onto the registered definitions through the defineTool soft-validation path', async () => { @@ -41,9 +41,9 @@ describe('presenters', () => { expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({ title: 'Inspect cordis runtime: api: tools', }) - expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) - expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' }) + expect(ctx.tools.get('cordis_try')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) + expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Stop temporary Cordis Plugin dyn-2' }) // Soft validation: presenter args that fail the schema render as no card, never a throw. - expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined() + expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 42 })).toBeUndefined() }) }) diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index 05a33848c0..0def756ebd 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -10,7 +10,7 @@ import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts' /** Mount a plugin whose `apply` touches one framework member, and report the error text. */ async function mountTouching(ctx: Awaited>, expr: string): Promise { - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`, }) expect(result.isError).toBe(true) @@ -41,7 +41,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'root-bypass', @@ -66,7 +66,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('rejects assignment to the façade rather than silently dropping it', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }', }) expect(result.isError).toBe(true) @@ -78,7 +78,7 @@ describe('sandbox context façade — escape surface is closed', () => { // `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return // guards reject that Context before the registration lands. const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'svc-ctx-escape', @@ -108,7 +108,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'host-async-svc', apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) }, }) - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'async-consumer', @@ -135,7 +135,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'introspector', @@ -157,7 +157,7 @@ describe('sandbox context façade — inject gate on services', () => { // mount does not declare it — reaching it would let the mount depend on a // provider cordis does not know about, so it is refused. const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }', }) expect(result.isError).toBe(true) @@ -167,7 +167,7 @@ describe('sandbox context façade — inject gate on services', () => { it('denies an undeclared live service reached through ctx.get too', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }', }) expect(result.isError).toBe(true) @@ -176,7 +176,7 @@ describe('sandbox context façade — inject gate on services', () => { it('allows a service the mount DID declare in inject', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_mount', { + const result = await call(ctx, 'cordis_try', { code: ` return { name: 'declared', @@ -186,17 +186,17 @@ describe('sandbox context façade — inject gate on services', () => { `, }) expect(result.isError).toBe(false) - expect(text(result)).toContain('state: active') + expect(text(result)).toContain('is running') }) it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => { // Without declared inject, Cordis cannot park the consumer when its provider unmounts. The // façade refuses access up front instead of leaving a zombie tool. const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }', }) - const undeclared = await call(ctx, 'cordis_mount', { + const undeclared = await call(ctx, 'cordis_try', { code: ` return { name: 'sloppy-consumer', @@ -229,7 +229,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { // function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now // returns the same name/description/parameters view as schemas(), with no execute. const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'reporter', @@ -241,7 +241,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { parameters: {}, ${CONTENT_OUTPUT_CODE} async execute() { - const view = ctx.tools.get('cordis_mount') + const view = ctx.tools.get('cordis_try') return [{ type: 'text', text: JSON.stringify({ hasExecute: 'execute' in view, hasPresentCall: 'presentCall' in view, @@ -259,13 +259,13 @@ describe('sandbox tools façade — get is a read-only schema view', () => { const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] } expect(shape.hasExecute).toBe(false) expect(shape.hasPresentCall).toBe(false) - expect(shape.name).toBe('cordis_mount') + expect(shape.name).toBe('cordis_try') expect(shape.keys).toEqual(['description', 'name', 'parameters']) }) it('ctx.tools.get returns undefined for an unknown tool', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { + await call(ctx, 'cordis_try', { code: ` return { name: 'unknown-probe', diff --git a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts index d305a92883..dbd73dfa14 100644 --- a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts @@ -30,10 +30,11 @@ describe('tool registration', () => { it('registers the three cordis tools with the documented schemas', async () => { const ctx = await setup() const names = ctx.tools.schemas().map(schema => schema.name) - expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_try', 'cordis_stop'])) + expect(names).not.toEqual(expect.arrayContaining(['cordis_mount', 'cordis_unmount'])) const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')! const props = (inspect.parameters as { properties: Record }).properties - expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) + expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events']) expect(props.name?.type).toBe('string') }) }) diff --git a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts index 92e5ee7a66..c16528d731 100644 --- a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts @@ -6,7 +6,7 @@ import * as tool from '../src/index.ts' import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** - * Disposal semantics: `cordis_unmount` reaches quiescence before returning, + * Disposal semantics: `cordis_stop` reaches quiescence before returning, * and disposing the tool-cordis fiber itself (the HMR path) cascades over the * whole dynamic subtree through the ordinary parent→child fiber lifecycle. */ @@ -15,46 +15,46 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('cordis_unmount', () => { +describe('cordis_stop', () => { it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + await call(ctx, 'cordis_try', { code: LISTENER_CODE }) ctx.tools.register(dummyTool('trigger_before')) expect(log).toHaveBeenCalledTimes(1) - const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + const result = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected cordis_unmount success') + if (result.isError) throw new Error('expected cordis_stop success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' }) - expect(text(result)).toContain('unmounted dyn-1') + expect(text(result)).toBe('Temporary Plugin dyn-1 was stopped and removed.') // Immediately after the awaited unmount, the listener must be gone — no // grace period, no eventual consistency. ctx.tools.register(dummyTool('trigger_after')) expect(log).toHaveBeenCalledTimes(1) - expect(text(await call(ctx, 'cordis_inspect', { what: 'dynamic' }))).toContain('(no dynamic plugins mounted)') + expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') }) it('unregisters a self-made tool on unmount', async () => { const ctx = await setup() - await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) expect(ctx.tools.get('reverse_text')).toBeDefined() - await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(ctx.tools.get('reverse_text')).toBeUndefined() }) it('rejects an unknown id, and a second unmount of the same id', async () => { const ctx = await setup() - const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' }) + const unknown = await call(ctx, 'cordis_stop', { id: 'dyn-99' }) expect(unknown.isError).toBe(true) - expect(text(unknown)).toContain('no dynamic plugin with id "dyn-99"') + expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"') - await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) - await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) - const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + const again = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) expect(again.isError).toBe(true) }) }) @@ -67,8 +67,8 @@ describe('HMR safety', () => { const fiber = await ctx.plugin(tool) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) - await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) expect(ctx.tools.get('reverse_text')).toBeDefined() await fiber.dispose() @@ -76,7 +76,7 @@ describe('HMR safety', () => { // The whole subtree is gone: the self-made tool, the cordis tools, and the // mounted listener (no log on a fresh tools/change). expect(ctx.tools.get('reverse_text')).toBeUndefined() - expect(ctx.tools.get('cordis_mount')).toBeUndefined() + expect(ctx.tools.get('cordis_try')).toBeUndefined() const calls = log.mock.calls.length ctx.tools.register(dummyTool('trigger_post_dispose')) expect(log).toHaveBeenCalledTimes(calls) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3754595f56..91131be9ea 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_stop', 'cordis_try', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 47c225008d..a54821fb5a 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -18,10 +18,10 @@ buffer 5| 6| "▌ " style 0-0 fg=yellow -7| "▌ ◌ Mount plugin into live cordis runtime " +7| "▌ ◌ Try temporary Cordis Plugin " style 0-0 fg=yellow style 2-2 fg=yellow bold - style 3-40 bold + style 3-30 bold 8| "▌ { " style 0-0 fg=yellow 9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " @@ -33,10 +33,10 @@ buffer 12| "▌ " style 0-0 fg=yellow 13| -14| "▌ ◌ Unmount dyn-1 " +14| "▌ ◌ Stop temporary Cordis Plugin dyn-1 " style 0-0 fg=yellow style 2-2 fg=yellow bold - style 3-16 bold + style 3-37 bold 15| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim 16| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index bdfc911e9e..2d32603a41 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -396,16 +396,16 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => { + it('pins cordis inspect, try, and stop cards with production presenters', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const calls = [ { id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } }, { id: 'cordis-2', - name: 'cordis_mount', + name: 'cordis_try', arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" }, }, - { id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } }, + { id: 'cordis-3', name: 'cordis_stop', arguments: { id: 'dyn-1' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) }) await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 990acd60c5..b88a5e338c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -569,7 +569,7 @@ const APP_EXAMPLES = [ title: 'Cordis Agent App Composition', label: 'examples/cordis-agent', config: 'examples/cordis-agent/cordis.yml', - summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.', + summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and try or stop in-memory temporary Plugins.', }, { id: 'acp', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8f7902dc9b..401a0ac0e8 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -208,12 +208,12 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-cordis', source: 'packages/cordis/tool-cordis/src/index.ts', requires: ['ctx.tools'], - writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'], + writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'], async mount(ctx) { await ctx.plugin(ToolCordis) }, note: - 'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.', + 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes.', }, { pkg: '@deepseek-ai/dsh-tool-fs', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index d575db8b01..6bad2ff170 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -36,8 +36,14 @@ return (ctx) => { name: 'snapshot_double', description: 'Double a number for executable snapshot verification.', parameters: { value: { type: 'number', required: true } }, + output: { + schema: { type: 'number' }, + render(_args, value) { + return [{ type: 'text', text: String(value) }] + } + }, async execute(args) { - return [{ type: 'text', text: String(args.value * 2) }] + return args.value * 2 } })) } @@ -143,10 +149,10 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: return text_chunks("WORKFLOW_CHILD_OK") if prompt == SNAPSHOT_PROMPT: - assert_advertised_tool(body, "cordis_mount") + assert_advertised_tool(body, "cordis_try") return tool_call_chunks( "advanced-mount", - "cordis_mount", + "cordis_try", {"code": SNAPSHOT_MOUNT_CODE}, ) if prompt == CODE_PROMPT: @@ -181,15 +187,18 @@ def advanced_tool_followup( """Advance the executable snapshot's deterministic parent tool chain.""" if not call_id.startswith("advanced-"): return None - if call_id == "advanced-mount" and tool_name == "cordis_mount": - if "mounted dyn-1" not in tool_text: - raise AssertionError(f"cordis_mount returned no mount id: {tool_text}") + if call_id == "advanced-mount" and tool_name == "cordis_try": + if "Temporary Plugin dyn-1 is running" not in tool_text: + raise AssertionError(f"cordis_try returned no temporary Plugin id: {tool_text}") assert_advertised_tool(body, "run_code") assert_advertised_tool(body, "snapshot_double") return tool_call_chunks( "advanced-code", "run_code", - {"code": "return await tools.snapshot_double({ value: 21 })"}, + { + "code": "return await tools.snapshot_double({ value: 21 })", + "description": "Run the temporary Plugin tool", + }, ) if call_id == "advanced-code" and tool_name == "run_code": if "42" not in tool_text: @@ -221,17 +230,17 @@ def advanced_tool_followup( if call_id == "advanced-workflow" and tool_name == "workflow": if "WORKFLOW_CHILD_OK" not in tool_text: raise AssertionError(f"workflow returned no expected child value: {tool_text}") - assert_advertised_tool(body, "cordis_unmount") + assert_advertised_tool(body, "cordis_stop") return tool_call_chunks( "advanced-unmount", - "cordis_unmount", + "cordis_stop", {"id": "dyn-1"}, ) - if call_id == "advanced-unmount" and tool_name == "cordis_unmount": - if "unmounted dyn-1" not in tool_text: - raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}") + if call_id == "advanced-unmount" and tool_name == "cordis_stop": + if "Temporary Plugin dyn-1 was stopped and removed." not in tool_text: + raise AssertionError(f"cordis_stop returned no stop result: {tool_text}") if "snapshot_double" in advertised_tool_names(body): - raise AssertionError("snapshot_double remained advertised after cordis_unmount") + raise AssertionError("snapshot_double remained advertised after cordis_stop") return text_chunks(SNAPSHOT_FINAL_TEXT) raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}") diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 499ddafdf8..521cf6650f 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -35,9 +35,23 @@ "surfaceOp": "append" }, { - "type": "step/start", + "type": "session/title", "seq": 2, "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, "data": { "turn": 1, "step": 1 @@ -45,25 +59,31 @@ }, { "type": "request/header", - "seq": 3, + "seq": 4, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "initial" @@ -71,7 +91,7 @@ }, { "type": "assistant/chunk", - "seq": 4, + "seq": 5, "time": 0, "data": { "turn": 1, @@ -85,7 +105,7 @@ }, { "type": "assistant/chunk", - "seq": 5, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -94,27 +114,8 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-mount", - "name": "cordis_mount", - "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } - } - }, - { - "type": "assistant/chunk", - "seq": 6, - "time": 0, - "data": { - "turn": 1, - "step": 1, - "chunk": { - "type": "block-end", - "index": 0, - "block": { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } + "name": "cordis_try", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } }, @@ -126,10 +127,13 @@ "turn": 1, "step": 1, "chunk": { - "type": "usage", - "usage": { - "inputTokens": 3, - "outputTokens": 3 + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -138,6 +142,22 @@ "type": "assistant/chunk", "seq": 8, "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 9, + "time": 0, "data": { "turn": 1, "step": 1, @@ -151,7 +171,7 @@ }, { "type": "assistant/message", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -160,39 +180,43 @@ { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 4, 5, 6, 7, - 8 + 8, + 9 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, "step": 1, "callId": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } }, { "type": "tool/result", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -201,19 +225,19 @@ "content": [ { "type": "text", - "text": "mounted dyn-1 (plugin \"\", state: active)" + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)." } ], "isError": false }, "sourceEventSeqs": [ - 10 + 11 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -222,7 +246,7 @@ }, { "type": "step/start", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -231,26 +255,32 @@ }, { "type": "request/header", - "seq": 14, + "seq": 15, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "snapshot_double", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -258,7 +288,7 @@ }, { "type": "assistant/chunk", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -272,7 +302,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -282,13 +312,13 @@ "index": 0, "id": "advanced-code", "name": "run_code", - "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } }, { "type": "assistant/chunk", - "seq": 17, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -300,14 +330,14 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } } }, { "type": "assistant/chunk", - "seq": 18, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -323,7 +353,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -338,7 +368,7 @@ }, { "type": "assistant/message", - "seq": 20, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -348,38 +378,55 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 15, 16, 17, 18, - 19 + 19, + 20 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 21, + "seq": 22, "time": 0, "data": { "turn": 1, "step": 2, "callId": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + }, + { + "type": "tool/code-dispatch-start", + "seq": 23, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + } } }, { "type": "tool/code-dispatch", - "seq": 22, + "seq": 24, "time": 0, "data": { "parentCallId": "advanced-code", @@ -389,12 +436,17 @@ "value": 21 }, "isError": false, - "resultSummary": "42" + "content": [ + { + "type": "text", + "text": "42" + } + ] } }, { "type": "tool/result", - "seq": 23, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -406,19 +458,16 @@ "text": "42" } ], - "isError": false, - "meta": { - "logs": [] - } + "isError": false }, "sourceEventSeqs": [ - 21 + 22 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 24, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -427,7 +476,7 @@ }, { "type": "step/start", - "seq": 25, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -436,7 +485,7 @@ }, { "type": "assistant/chunk", - "seq": 26, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -450,7 +499,7 @@ }, { "type": "assistant/chunk", - "seq": 27, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -466,7 +515,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -485,7 +534,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -501,7 +550,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -516,7 +565,7 @@ }, { "type": "assistant/message", - "seq": 31, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -529,23 +578,27 @@ "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 26, - 27, 28, 29, - 30 + 30, + 31, + 32 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 32, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -557,7 +610,7 @@ }, { "type": "tool/result", - "seq": 33, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -572,13 +625,13 @@ "isError": false }, "sourceEventSeqs": [ - 32 + 34 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 34, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -587,7 +640,7 @@ }, { "type": "step/start", - "seq": 35, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -596,7 +649,7 @@ }, { "type": "assistant/chunk", - "seq": 36, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -610,7 +663,7 @@ }, { "type": "assistant/chunk", - "seq": 37, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -626,7 +679,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -645,7 +698,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -661,7 +714,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -676,7 +729,7 @@ }, { "type": "assistant/message", - "seq": 41, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -689,23 +742,27 @@ "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 36, - 37, 38, 39, - 40 + 40, + 41, + 42 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 42, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -717,7 +774,7 @@ }, { "type": "tool/result", - "seq": 43, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -732,13 +789,13 @@ "isError": false }, "sourceEventSeqs": [ - 42 + 44 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 44, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -747,7 +804,7 @@ }, { "type": "step/start", - "seq": 45, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -756,7 +813,7 @@ }, { "type": "assistant/chunk", - "seq": 46, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -770,7 +827,7 @@ }, { "type": "assistant/chunk", - "seq": 47, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -779,14 +836,14 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "argumentsDelta": "{\"id\": \"dyn-1\"}" } } }, { "type": "assistant/chunk", - "seq": 48, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -797,7 +854,7 @@ "block": { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "arguments": "{\"id\": \"dyn-1\"}" } } @@ -805,7 +862,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -821,7 +878,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -836,7 +893,7 @@ }, { "type": "assistant/message", - "seq": 51, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -845,39 +902,43 @@ { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "arguments": "{\"id\": \"dyn-1\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 46, - 47, 48, 49, - 50 + 50, + 51, + 52 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 52, + "seq": 54, "time": 0, "data": { "turn": 1, "step": 5, "callId": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "arguments": "{\"id\": \"dyn-1\"}" } }, { "type": "tool/result", - "seq": 53, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -886,19 +947,19 @@ "content": [ { "type": "text", - "text": "unmounted dyn-1 (plugin \"\")" + "text": "Temporary Plugin dyn-1 was stopped and removed." } ], "isError": false }, "sourceEventSeqs": [ - 52 + 54 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 54, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -907,7 +968,7 @@ }, { "type": "step/start", - "seq": 55, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -916,25 +977,31 @@ }, { "type": "request/header", - "seq": 56, + "seq": 58, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -942,7 +1009,7 @@ }, { "type": "assistant/chunk", - "seq": 57, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -956,7 +1023,7 @@ }, { "type": "assistant/chunk", - "seq": 58, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -970,7 +1037,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -987,7 +1054,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1003,7 +1070,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1018,7 +1085,7 @@ }, { "type": "assistant/message", - "seq": 62, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1029,23 +1096,27 @@ "text": "ADVANCED_EXECUTABLE_OK" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 57, - 58, 59, 60, - 61 + 61, + 62, + 63 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 63, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1054,7 +1125,7 @@ }, { "type": "turn/end", - "seq": 64, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1113,9 +1184,29 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "step/start", + "type": "session/title", "seq": 2, "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, "data": { "turn": 1, "step": 1 @@ -1129,25 +1220,31 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 3, + "seq": 4, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "initial" @@ -1161,7 +1258,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 4, + "seq": 5, "time": 0, "data": { "turn": 1, @@ -1181,7 +1278,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -1190,33 +1287,8 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-mount", - "name": "cordis_mount", - "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } - } - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "assistant/chunk", - "seq": 6, - "time": 0, - "data": { - "turn": 1, - "step": 1, - "chunk": { - "type": "block-end", - "index": 0, - "block": { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" - } + "name": "cordis_try", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1234,10 +1306,13 @@ "turn": 1, "step": 1, "chunk": { - "type": "usage", - "usage": { - "inputTokens": 3, - "outputTokens": 3 + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1252,6 +1327,28 @@ "type": "assistant/chunk", "seq": 8, "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, "data": { "turn": 1, "step": 1, @@ -1271,7 +1368,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1280,21 +1377,25 @@ { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 4, 5, 6, 7, - 8 + 8, + 9 ], "surfaceOp": "append" } @@ -1306,14 +1407,14 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, "step": 1, "callId": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + "name": "cordis_try", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } } @@ -1324,7 +1425,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1333,13 +1434,13 @@ "content": [ { "type": "text", - "text": "mounted dyn-1 (plugin \"\", state: active)" + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)." } ], "isError": false }, "sourceEventSeqs": [ - 10 + 11 ], "surfaceOp": "append" } @@ -1351,7 +1452,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1366,7 +1467,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1381,26 +1482,32 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 14, + "seq": 15, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "snapshot_double", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -1414,7 +1521,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1434,7 +1541,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1444,32 +1551,7 @@ "index": 0, "id": "advanced-code", "name": "run_code", - "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" - } - } - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "assistant/chunk", - "seq": 17, - "time": 0, - "data": { - "turn": 1, - "step": 2, - "chunk": { - "type": "block-end", - "index": 0, - "block": { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" - } + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } } } @@ -1483,6 +1565,31 @@ "type": "assistant/chunk", "seq": 18, "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 19, + "time": 0, "data": { "turn": 1, "step": 2, @@ -1503,7 +1610,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1524,7 +1631,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 20, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1534,20 +1641,24 @@ "type": "tool-call", "id": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 15, 16, 17, 18, - 19 + 19, + 20 ], "surfaceOp": "append" } @@ -1559,14 +1670,33 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 21, + "seq": 22, "time": 0, "data": { "turn": 1, "step": 2, "callId": "advanced-code", "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/code-dispatch-start", + "seq": 23, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + } } } } @@ -1577,7 +1707,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 22, + "seq": 24, "time": 0, "data": { "parentCallId": "advanced-code", @@ -1587,7 +1717,12 @@ "value": 21 }, "isError": false, - "resultSummary": "42" + "content": [ + { + "type": "text", + "text": "42" + } + ] } } } @@ -1598,7 +1733,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 23, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1610,13 +1745,10 @@ "text": "42" } ], - "isError": false, - "meta": { - "logs": [] - } + "isError": false }, "sourceEventSeqs": [ - 21 + 22 ], "surfaceOp": "append" } @@ -1628,7 +1760,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 24, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -1643,7 +1775,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 25, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -1658,7 +1790,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 26, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1678,7 +1810,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 27, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1700,7 +1832,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1725,7 +1857,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1747,7 +1879,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1768,7 +1900,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 31, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1781,17 +1913,21 @@ "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 26, - 27, 28, 29, - 30 + 30, + 31, + 32 ], "surfaceOp": "append" } @@ -1803,7 +1939,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 32, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1822,11 +1958,303 @@ "childSessionId": "{{child-1}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "session/title", + "seq": 2, + "time": 0, + "data": { + "title": "Reply with exactly DIRECT_CHILD_OK and", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/header", + "seq": 4, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" + }, + "system": "{{system}}", + "tools": [ + "bash", + "cordis_inspect", + "cordis_stop", + "cordis_try", + "run_code", + "skill", + "snapshot_double", + "subagent", + "task_kill", + "task_list", + "task_output", + "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "DIRECT_CHILD_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "assistant/message", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 5, + 6, + 7, + 8, + 9 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/end", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, { "method": "subagent.finished", "payload": { "provider": "spawn", - "agentId": "{{agent-1}}", + "agentId": "{{child-1}}", "parentSessionId": "{{parent}}", "childSessionId": "{{child-1}}", "status": "ok", @@ -1845,7 +2273,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 33, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -1860,7 +2288,7 @@ "isError": false }, "sourceEventSeqs": [ - 32 + 34 ], "surfaceOp": "append" } @@ -1872,7 +2300,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 34, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -1887,7 +2315,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 35, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -1902,7 +2330,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 36, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -1922,7 +2350,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 37, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -1944,7 +2372,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -1969,7 +2397,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -1991,7 +2419,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2012,7 +2440,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 41, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2025,17 +2453,21 @@ "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 36, - 37, 38, 39, - 40 + 40, + 41, + 42 ], "surfaceOp": "append" } @@ -2047,7 +2479,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 42, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2066,11 +2498,303 @@ "childSessionId": "{{child-2}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "session/title", + "seq": 2, + "time": 0, + "data": { + "title": "Reply with exactly WORKFLOW_CHILD_OK and", + "messageSeqs": [ + 1 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/header", + "seq": 4, + "time": 0, + "data": { + "header": { + "config": { + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" + }, + "system": "{{system}}", + "tools": [ + "bash", + "cordis_inspect", + "cordis_stop", + "cordis_try", + "run_code", + "skill", + "snapshot_double", + "subagent", + "task_kill", + "task_list", + "task_output", + "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "WORKFLOW_CHILD_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/chunk", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "assistant/message", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 5, + 6, + 7, + 8, + 9 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/end", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, { "method": "subagent.finished", "payload": { "provider": "spawn", - "agentId": "{{agent-2}}", + "agentId": "{{child-2}}", "parentSessionId": "{{parent}}", "childSessionId": "{{child-2}}", "status": "ok", @@ -2089,7 +2813,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 43, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2104,7 +2828,7 @@ "isError": false }, "sourceEventSeqs": [ - 42 + 44 ], "surfaceOp": "append" } @@ -2116,7 +2840,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 44, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2131,7 +2855,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 45, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2146,7 +2870,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 46, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2166,7 +2890,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 47, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -2175,7 +2899,7 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "argumentsDelta": "{\"id\": \"dyn-1\"}" } } @@ -2188,7 +2912,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -2199,7 +2923,7 @@ "block": { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "arguments": "{\"id\": \"dyn-1\"}" } } @@ -2213,7 +2937,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -2235,7 +2959,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -2256,7 +2980,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 51, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -2265,65 +2989,24 @@ { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_unmount", + "name": "cordis_stop", "arguments": "{\"id\": \"dyn-1\"}" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 46, - 47, 48, 49, - 50 - ], - "surfaceOp": "append" - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "tool/call", - "seq": 52, - "time": 0, - "data": { - "turn": 1, - "step": 5, - "callId": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "tool/result", - "seq": 53, - "time": 0, - "data": { - "turn": 1, - "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "unmounted dyn-1 (plugin \"\")" - } - ], - "isError": false - }, - "sourceEventSeqs": [ + 50, + 51, 52 ], "surfaceOp": "append" @@ -2335,9 +3018,54 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "step/end", + "type": "tool/call", "seq": 54, "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "name": "cordis_stop", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 55, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was stopped and removed." + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 54 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 56, + "time": 0, "data": { "turn": 1, "step": 5 @@ -2351,7 +3079,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 55, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -2366,25 +3094,31 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 56, + "seq": 58, "time": 0, "data": { "header": { "config": { - "model": "smoke-model" + "provider": "deepseek", + "model": "smoke-model", + "reasoningEffort": "high" }, "system": "{{system}}", "tools": [ "bash", - "bash_kill", - "bash_output", "cordis_inspect", - "cordis_mount", - "cordis_unmount", + "cordis_stop", + "cordis_try", "run_code", "skill", "subagent", + "task_kill", + "task_list", + "task_output", "workflow" + ], + "messagePrefix": [ + "{{messagePrefix}}" ] }, "reason": "change" @@ -2398,7 +3132,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 57, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -2418,7 +3152,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 58, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -2438,7 +3172,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -2461,7 +3195,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -2483,7 +3217,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -2504,7 +3238,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 62, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -2515,17 +3249,21 @@ "text": "ADVANCED_EXECUTABLE_OK" } ], + "provenance": { + "provider": "deepseek", + "model": "smoke-model" + }, "usage": { "inputTokens": 3, "outputTokens": 3 } }, "sourceEventSeqs": [ - 57, - 58, 59, 60, - 61 + 61, + 62, + 63 ], "surfaceOp": "append" } @@ -2537,7 +3275,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 63, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -2552,7 +3290,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 64, + "seq": 66, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index beb80d3c85..820fcb50c2 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,13 +1,14 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 948c835148..ab58f50eed 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,13 +1,14 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 3d09295028..3549b90945 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,66 +1,68 @@ -{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} -{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} -{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"} -{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} -{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} -{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} -{"type":"tool/call","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"} -{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"} -{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} -{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} -{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From 826c6fd2436dddea397ed7969ad56a343783b42c Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 27 Jul 2026 14:59:24 +0800 Subject: [PATCH 02/16] feat(web): present Cordis lifecycle tools --- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 2 +- apps/web/tests/cordis-tool-round.e2e.ts | 130 ++++++++++++++++++ apps/web/tests/scaffold.ts | 13 ++ .../snapshots/cordis-tool-round/session.jsonl | 48 +++++++ .../cordis-tool-round/ui.expected.md | 44 ++++++ apps/web/tsconfig.json | 3 +- examples/README.i18n.yaml | 4 +- examples/README.md | 2 +- examples/README.zh.md | 2 +- examples/acp-agent/cordis-tools.cordis.yml | 10 ++ examples/cordis-agent/README.i18n.yaml | 4 +- examples/cordis-agent/README.md | 10 +- examples/cordis-agent/README.zh.md | 12 +- examples/web-cordis/.gitignore | 2 + examples/web-cordis/cordis.yml | 18 +++ package.json | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/GenericToolCard.tsx | 1 + .../src/client/chat/ToolRow.module.css | 15 ++ .../src/client/chat/ToolRow.tsx | 5 +- .../src/client/contract/tool-call-model.ts | 19 ++- .../tests/chat-code-subcalls.spec.tsx | 22 +++ .../tests/chat-tool-row.spec.tsx | 30 ++++ .../tests/chat-toolview-slot.spec.tsx | 21 ++- scripts/demo-cordis.mjs | 24 ++++ tsconfig.host.json | 1 + 30 files changed, 428 insertions(+), 30 deletions(-) create mode 100644 apps/web/tests/cordis-tool-round.e2e.ts create mode 100644 apps/web/tests/snapshots/cordis-tool-round/session.jsonl create mode 100644 apps/web/tests/snapshots/cordis-tool-round/ui.expected.md create mode 100644 examples/acp-agent/cordis-tools.cordis.yml create mode 100644 examples/web-cordis/.gitignore create mode 100644 examples/web-cordis/cordis.yml create mode 100644 scripts/demo-cordis.mjs diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index e9afb7ed05..5ac1989116 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: de469f18cbddd42249e5d459949b745634b4e399 -2026-07-08-self-referential-cordis-toolset.zh.md: 7fdf438b6c144d152d3e55a07d416db9d5a5c0c6 +2026-07-08-self-referential-cordis-toolset.md: c531b8b7da988b9381f7a1c78f4017977dd321d8 +2026-07-08-self-referential-cordis-toolset.zh.md: ae3ef1170d29cf896d4a5c54a4b23289b981b88f diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index de469f18cb..c531b8b7da 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -54,7 +54,7 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( ### Configuration, rendering, and observability -The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. +The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` plus one shared Cordis accent; the try row retains the shared JavaScript expansion and syntax highlighting. Model-visible ⟺ logged holds with no new session event type: try and stop are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 7fdf438b6c..ae3ef1170d 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -54,7 +54,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin` 以及统一的 Cordis 强调色;try 行仍使用共用的 JavaScript 展开视图和语法高亮。 「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:try 与 stop 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts new file mode 100644 index 0000000000..fb15f77ab5 --- /dev/null +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -0,0 +1,130 @@ +// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real +// model through inspect, try, and stop; replay pins the same shipped Web +// composition, durable calls, generic rows, highlighted Plugin source, and +// conversation accessibility tree. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const CORDIS_TOOLS = ['cordis_inspect', 'cordis_try', 'cordis_stop'] as const +const TRY_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' +const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". ' + + `Then call cordis_try with this exact code: ${JSON.stringify(TRY_CODE)}. ` + + 'Read its returned id and call cordis_stop with that exact id. ' + + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.' + +function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { + const turnEnd = events.findLast( + (event): event is Extract => event.type === 'turn/end', + ) + const reason = turnEnd?.data.reason + const reasonSummary = reason?.kind === 'error' + ? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status } + : { kind: reason?.kind } + expect(reasonSummary).toEqual({ kind: 'completed' }) + + const calls = events.filter( + (event): event is Extract => event.type === 'tool/call', + ) + expect(calls.map(event => event.data.name)).toEqual(CORDIS_TOOLS) + + const callIds = new Set(calls.map(event => String(event.data.callId))) + const results = events.filter( + (event): event is Extract => + event.type === 'tool/result' && callIds.has(String(event.data.callId)), + ) + expect(results).toHaveLength(CORDIS_TOOLS.length) + expect(results.every(event => !event.data.isError)).toBe(true) +} + +describe('web e2e: Cordis tools use the generic row variants', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + cordisTools: true, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + assertCompleteCordisLifecycle(sessionEvents) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('the durable log carries one complete Cordis lifecycle', () => { + assertCompleteCordisLifecycle(sessionEvents) + }) + + it.skipIf(MODE === 'record')('renders Cordis lifecycle titles over the generic row mechanics', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows')) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + + const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first() + await inspectRow.waitFor({ timeout: 10_000 }) + + const tryRow = page.locator('[data-tool="cordis_try"]').filter({ hasText: 'Try temporary Plugin' }).first() + await tryRow.waitFor({ timeout: 10_000 }) + await tryRow.locator('button[aria-expanded]').click() + await expect.poll(() => tryRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) + .toContain(TRY_CODE) + + const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop temporary Plugin' }).first() + await stopRow.waitFor({ timeout: 10_000 }) + await expect.poll(() => stopRow.textContent()).toContain('dyn-') + await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok') + }) + + it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria')) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean: no page errors or reconnect churn', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 89269d743f..f4797e0076 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -40,6 +40,7 @@ import SessionStore, { type SessionHeader, } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' @@ -122,6 +123,12 @@ export interface LaunchOptions { * insertion is needed. */ toolsMode?: 'native' | 'code' | 'both' + /** + * Insert the opt-in self-referential Cordis tools into the shipped tree. + * Record and replay use the same tool surface, so captured request headers + * remain reconstructable without making the tools a product default. + */ + cordisTools?: boolean } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -174,6 +181,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise console.log('status →', status)) } }"}) [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier @@ -28,8 +30,8 @@ The intended demo is staged — verify the listener link first, then let the age [tool call] cordis_stop({"id": "dyn-1"}) ``` -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer. +Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and try two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model tries a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index 346c4ef936..c9991dea88 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、尝试仅存于内存的临时 Plugin,并再次停止它们。临时 Plugin 可跨 turn 保持活跃,但会在 stop、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -10,7 +10,9 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: @@ -20,7 +22,7 @@ pnpm run demo:cordis [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier @@ -28,8 +30,8 @@ pnpm run demo:cordis [tool call] cordis_stop({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可尝试两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型尝试一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/web-cordis/.gitignore b/examples/web-cordis/.gitignore new file mode 100644 index 0000000000..4da346bc81 --- /dev/null +++ b/examples/web-cordis/.gitignore @@ -0,0 +1,2 @@ +.sessions/ +.storages/ diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml new file mode 100644 index 0000000000..80b598c696 --- /dev/null +++ b/examples/web-cordis/cordis.yml @@ -0,0 +1,18 @@ +# Opt-in Web composition for inspecting the self-referential Cordis tools. +# Temporary Plugin code can reach every injected live capability; treat this +# deployment like shell access, not as a security boundary. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../apps/cli/cordis.yml + patches: + # AppCLIEntry normally injects this assembly-owned path before `dsh web` + # boots; the standalone Cordis launcher needs the equivalent patch here. + - id: webserver + config: + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + - insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/package.json b/package.json index 90194a57d3..aca03d520c 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", + "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 56923eff77..e4a41bcc0b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 -README.zh.md: 88992176165ab11050a30c7df381479796908ba2 +README.md: 4e12b82070bd27a0724cc3e43e952a4221339f49 +README.zh.md: b4936501b68e110ff538fec28bb52a3a72d7b849 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 453922dafd..4e12b82070 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` with a shared Cordis accent; try keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8899217616..b4936501b6 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin`;try 行保留 code 变体的可展开源码渲染。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 7dbefdc139..5cb2126f34 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr return ( +
= { write: 'write', edit: 'edit', run_code: 'code', + cordis_inspect: 'read', + cordis_try: 'code', + cordis_stop: 'others', +} + +/** Tool-owned titles that refine a generic row variant without replacing it. */ +const TOOL_TITLES: Record = { + cordis_inspect: 'Inspect', + cordis_try: 'Try temporary Plugin', + cordis_stop: 'Stop temporary Plugin', } /** @@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool - // name rides the mutable summary slot so no information is lost. - const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base + // name rides the mutable summary slot unless the tool owns a specific title. + const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + ? `${toolName} · ${base}` + : base return { variant, - title: VARIANT_TITLES[variant], + title: toolTitle ?? VARIANT_TITLES[variant], summary, body: deriveBody(variant, argsRaw), state, diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 972cb930d2..003a3e6b13 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -167,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { + const parent = 'call-cordis' + const code = 'return { name: "audit", apply(ctx) {} }' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), + subCall(12, parent, 2, 'cordis_try', { code }, 'Temporary Plugin dyn-2 is running'), + subCall(13, parent, 3, 'cordis_stop', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was stopped and removed.'), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + const nest = view.container.querySelector('[data-subcalls]')! + + expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const tried = nest.querySelector('[data-variant="code"]') + expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) + expect(nest.querySelector('[data-tool="cordis_stop"]')?.textContent) + .toContain('Stop temporary Plugindyn-2') + + fireEvent.click(tried!.querySelector('button[aria-expanded]')!) + expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index a221a4028b..5c0e8d6337 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -31,6 +31,9 @@ describe('tool-call-model', () => { expect(classifyTool('grep')).toBe('search') expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') + expect(classifyTool('cordis_inspect')).toBe('read') + expect(classifyTool('cordis_try')).toBe('code') + expect(classifyTool('cordis_stop')).toBe('others') expect(classifyTool('todo_write')).toBe('others') }) @@ -67,6 +70,33 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull() expect(toolRowModel('bash', result({ call: null })).body).toBeNull() }) + + it('gives Cordis lifecycle tools action titles over their generic variants', () => { + expect(toolRowModel('cordis_inspect', running({ + name: 'cordis_inspect', + argsRaw: '{"what":"api","name":"tools"}', + }))).toMatchObject({ + variant: 'read', + title: 'Inspect', + summary: 'api', + }) + expect(toolRowModel('cordis_try', running({ + name: 'cordis_try', + argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}', + }))).toMatchObject({ + variant: 'code', + title: 'Try temporary Plugin', + summary: 'return { name: "audit", apply(ctx) {} }', + body: 'return { name: "audit", apply(ctx) {} }', + }) + expect(toolRowModel('cordis_stop', result({ + call: { name: 'cordis_stop', argsRaw: '{"id":"dyn-2"}' }, + }))).toMatchObject({ + variant: 'others', + title: 'Stop temporary Plugin', + summary: 'dyn-2', + }) + }) }) describe('ToolRow', () => { diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 5495750d23..f1a6c6da17 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -12,7 +12,7 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, @@ -162,6 +162,25 @@ describe('keyed toolview hole through the real machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { + const code = 'return { name: "audit", apply(ctx) {} }' + const b = await bench([ + toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'), + toolResult(4, 'cordis-2', 'cordis_try', JSON.stringify({ code })), + toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'), + ]) + const view = mountApp(b.slots) + + expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const tried = view.container.querySelector('[data-variant="code"]') + expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) + expect(view.container.querySelector('[data-tool="cordis_stop"]')?.textContent) + .toContain('Stop temporary Plugindyn-2') + + fireEvent.click(tried!.querySelector('button[aria-expanded]')!) + expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => { const b = await bench([toolResult(3, 'c1', 'bash')]) const view = mountApp(b.slots) diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs new file mode 100644 index 0000000000..94bbea1332 --- /dev/null +++ b/scripts/demo-cordis.mjs @@ -0,0 +1,24 @@ +/** + * Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting + * to TUI. This is a repository demo wrapper, not a product CLI feature. + */ +import { spawn } from 'node:child_process' + +const SURFACES = new Map([ + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']], + // `dsh web` does not accept alternate configs yet. The TUI config escape + // hatch still boots this browser-only tree; the config owns port 3081. + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']], + ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], +]) + +const surface = process.argv[2] ?? 'tui' +const args = SURFACES.get(surface) +if (args === undefined || process.argv.length > 3) { + console.error('usage: pnpm run demo:cordis [tui|web|acp]') + process.exit(2) +} + +if (surface === 'web') console.log('Cordis Web: http://127.0.0.1:3081') +const child = spawn(process.execPath, args, { stdio: 'inherit' }) +child.on('exit', (code, signal) => { process.exit(signal === null ? code ?? 1 : 1) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..e856953daa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -20,6 +20,7 @@ "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", + "apps/web/tests/cordis-tool-round.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From 6a7307de37842b0a4ca41711bde82785fa7f837e Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:18 +0800 Subject: [PATCH 03/16] fix(llm-pi-ai): classify transport truncations Personal customization replayed onto upstream master source. --- ...nsport-truncation-classification.i18n.yaml | 6 ++++ ...-ai-transport-truncation-classification.md | 35 +++++++++++++++++++ ...-transport-truncation-classification.zh.md | 35 +++++++++++++++++++ packages/llm/llm-pi-ai/src/stream.ts | 21 ++++++++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 9 +++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml new file mode 100644 index 0000000000..567f8e8c17 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.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 +2026-07-22-pi-ai-transport-truncation-classification.md: 119200a788c0b0521f385f4cf4e6adf05a0512f9 +2026-07-22-pi-ai-transport-truncation-classification.zh.md: 6a1bb478a86fc6ab726968b3df5752e0ad7fc9e6 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md new file mode 100644 index 0000000000..119200a788 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md @@ -0,0 +1,35 @@ +# Agent Note: Classify pi-ai transport truncations from flattened message text + +Status: implemented + +English | [中文](2026-07-22-pi-ai-transport-truncation-classification.zh.md) + +## Problem + +A TUI run whose model connection dropped mid-stream surfaced the single notice `terminated`, and a truncated Anthropic response surfaced `Anthropic stream ended before message_stop`. Both are transport truncations — the connection died before the provider's terminal SSE event — yet `classifyPiAiError` in `dsh-llm-pi-ai` mapped neither, falling through to the catch-all `PI_AI_ERROR`. Because `PI_AI_ERROR` is not in `llm-retry`'s `DEFAULT_RETRYABLE_CODES` (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`), a recoverable drop was treated as a permanent failure and never retried. + +The detail loss is upstream and unrecoverable in the adapter: pi-ai reduces a caught error to `error.message` (`api/anthropic-messages.js`: `errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`) before pushing the terminal `error` event, discarding the original `Error` and its `cause` chain. undici carries the actionable `SocketError` on `cause` but hands the fetch wrapper a bare `terminated`; pi-ai keeps only that word. pi-ai `SimpleStreamOptions` exposes no fetch/dispatcher/client hook we could use to capture the `cause` ourselves before it is flattened. + +## Decision + +- `classifyPiAiError` recognizes two more transport wordings and maps both to `TRANSPORT`: + - a mid-stream socket drop rendered as a bare `terminated` (undici) or `Premature close` (Node stream layer); + - a stream truncated before its terminal event, which each pi-ai provider throws with its own wording (`Anthropic stream ended before message_stop`, `… before a terminal response event`, `… ended without a terminal event`, `Stream ended without finish_reason`), matched on `stream ended before/without`. +- The classifier carries an `XXX(pi-ai upstream)` note naming the flattening site and stating the intended fix: classify on `code`/`cause` if pi-ai ever forwards the original `Error` or a hook that lets us capture the `cause`. Classification stays best-effort text matching until then. +- `llm-pi-ai/README.md` gains a Known-Limitations bullet recording that pi-ai flattens the cause chain and that harness codes are therefore classified from message text. + +Classification stays on message text because that is the only signal pi-ai delivers; the `XXX` marks it as a workaround, not the desired end state. + +## Alternatives considered + +**Capture the `cause` via a pi-ai fetch/dispatcher/client hook.** Rejected: pi-ai 0.81.1 exposes none. `StreamOptions` offers only `onPayload`/`onResponse`; `onResponse` fires before the body stream is consumed, so it cannot observe a mid-stream drop. The Anthropic path accepts a `client` object, but constructing and injecting a provider SDK client per request to intercept transport errors reaches around the adapter seam for one diagnostic string. + +**Leave both as `PI_AI_ERROR` and widen `llm-retry`'s retryable set.** Rejected: `PI_AI_ERROR` is the catch-all for genuinely unclassified failures, including non-retryable ones (a malformed provider response, an unexpected SDK bug). Making the catch-all retryable would retry failures that will never succeed; the fix is to classify the recoverable case, not to blur the bucket. + +**Wrap the flattened error in an `LlmError('TRANSPORT', { cause })` in the adapter, mirroring the DeepSeek adapter.** Rejected here: the DeepSeek adapter wraps a *pre-response* `fetch` rejection whose `cause` is still intact, so chaining preserves real detail. In the pi-ai path the terminal event's `errorMessage` is already a flattened string with no `cause` to chain, so wrapping would add a layer without recovering anything; classifying the code is the only value left to add. + +## Consequences + +- A mid-stream transport drop and a pre-terminal stream truncation now carry `TRANSPORT`, so a composed `llm-retry` policy retries them by default instead of failing the turn. +- The notice text is unchanged (`terminated` / `Anthropic stream ended before message_stop`): the cause detail is gone before the adapter sees it, so `errorChain` has nothing more to render. Only the routed `code` improved. +- Classification remains string-matching and provider-wording-dependent: a future pi-ai release that rewords these errors would silently fall back to `PI_AI_ERROR` until the patterns are updated. The `XXX` note points at the durable fix (route on a forwarded `code`/`cause`). diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md new file mode 100644 index 0000000000..6a1bb478a8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 从扁平化的消息文本中分类 pi-ai 传输层截断 + +Status: implemented + +[English](2026-07-22-pi-ai-transport-truncation-classification.md) | 中文 + +## Problem + +一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 `terminated` 通知,而一个被截断的 Anthropic 响应则浮现出 `Anthropic stream ended before message_stop`。两者都是传输层截断——连接在提供方的终止 SSE 事件之前就已断开——然而 `dsh-llm-pi-ai` 中的 `classifyPiAiError` 对两者都不匹配,最终落入兜底的 `PI_AI_ERROR`。由于 `PI_AI_ERROR` 不在 `llm-retry` 的 `DEFAULT_RETRYABLE_CODES`(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`)中,一次可恢复的断开被当作永久性失败处理,从未被重试。 + +细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 把可操作的 `SocketError` 携带在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 + +## Decision + +- `classifyPiAiError` 识别另外两种传输层措辞,并将两者都映射为 `TRANSPORT`: + - 流式输出中途的套接字断开,呈现为裸的 `terminated`(undici)或 `Premature close`(Node 流层); + - 在终止事件之前被截断的流,每个 pi-ai 提供方各自抛出不同措辞(`Anthropic stream ended before message_stop`、`… before a terminal response event`、`… ended without a terminal event`、`Stream ended without finish_reason`),统一按 `stream ended before/without` 匹配。 +- 该分类器带有一条 `XXX(pi-ai upstream)` 注记,点名扁平化发生的位置并说明期望的修复方式:如果 pi-ai 有朝一日转发原始的 `Error` 或提供一个让我们捕获 `cause` 的钩子,就改为基于 `code`/`cause` 分类。在此之前分类仍是尽力而为的文本匹配。 +- `llm-pi-ai/README.md` 新增一条 Known-Limitations 条目,记录 pi-ai 会扁平化 cause 链,因此 harness code 是从消息文本中分类出来的。 + +分类仍然基于消息文本,因为那是 pi-ai 唯一交付的信号;`XXX` 标明它是一个权宜之计,而非期望的最终状态。 + +## Alternatives considered + +**通过 pi-ai 的 fetch/dispatcher/client 钩子捕获 `cause`。** 否决:pi-ai 0.81.1 一个都没暴露。`StreamOptions` 只提供 `onPayload`/`onResponse`;`onResponse` 在响应体流被消费之前触发,因此无法观察到流式输出中途的断开。Anthropic 路径接受一个 `client` 对象,但为拦截传输错误而为每个请求构造并注入一个提供方 SDK client,只为一个诊断字符串就越过了适配器的服务边界。 + +**把两者都保留为 `PI_AI_ERROR`,并放宽 `llm-retry` 的可重试集合。** 否决:`PI_AI_ERROR` 是真正未分类失败的兜底,其中包括不可重试的失败(畸形的提供方响应、意料之外的 SDK bug)。让兜底可重试会重试那些永远不会成功的失败;修复之道是分类出可恢复的那种情况,而不是模糊这个类别。 + +**在适配器里把扁平化后的错误包装成 `LlmError('TRANSPORT', { cause })`,仿照 DeepSeek 适配器。** 在此否决:DeepSeek 适配器包装的是拿到响应之前的 `fetch` 拒绝,其 `cause` 仍然完好,因此链式包装保留了真实细节。而在 pi-ai 路径中,终止事件的 `errorMessage` 已经是一个没有 `cause` 可链的扁平化字符串,因此包装只会加一层却恢复不了任何东西;分类出 code 是唯一还能增加的价值。 + +## Consequences + +- 流式输出中途的传输层断开和终止前的流截断现在都携带 `TRANSPORT`,因此组合出的 `llm-retry` 策略会默认重试它们,而不是让该轮次失败。 +- 通知文本不变(`terminated` / `Anthropic stream ended before message_stop`):cause 细节在适配器看到之前就已丢失,因此 `errorChain` 没有更多内容可渲染。只有被路由的 `code` 得到了改善。 +- 分类仍然依赖字符串匹配且依赖提供方的措辞:未来某个 pi-ai 版本若改写这些错误的措辞,就会静默回退到 `PI_AI_ERROR`,直到模式被更新。`XXX` 注记指向那个持久的修复方式(基于转发的 `code`/`cause` 路由)。 diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 049b10d930..22aa7cb579 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -28,6 +28,14 @@ export function mapUsage(usage: PiUsage): TokenUsage { } } +// XXX(pi-ai upstream): pi-ai flattens the caught error to `error.message` +// (api/anthropic-messages.js: `errorMessage = error instanceof Error ? +// error.message : JSON.stringify(error)`), discarding the original Error and its +// `cause` chain before it reaches us. undici carries the actionable transport +// detail on `cause` (e.g. `SocketError: other side closed`) but hands the fetch +// wrapper a bare `terminated`, so we are left pattern-matching terse words here. +// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets +// us capture the cause ourselves), classify on `code`/`cause` instead of text. function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE @@ -35,8 +43,19 @@ function classifyPiAiError(message: string): string { if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' + // A stream truncated before the provider's terminal event: each pi-ai provider + // throws its own wording when the wire closes mid-response without a terminal + // event (`… stream ended before message_stop`, `… before a terminal response + // event`, `… ended without a terminal event`, `Stream ended without + // finish_reason`). The connection dropped mid-response, so this is a transport + // truncation, not a model-level error. + if (/stream ended (?:before|without)\b/i.test(message)) return 'TRANSPORT' if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) - || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) { + || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message) + // undici renders a mid-stream socket drop as a bare `terminated` (its + // `cause` — the real SocketError — was flattened away upstream); Node's + // stream layer says `Premature close`. + || /\bterminated\b|premature close/i.test(message)) { return 'TRANSPORT' } return 'PI_AI_ERROR' diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 661a930e94..ca1530b7f5 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -578,6 +578,15 @@ describe('mapStopReason / mapUsage', () => { 'other side closed', 'HTTP2 request did not get a response', 'WebSocket closed unexpectedly', + // undici flattens a mid-stream socket drop to this bare word (its SocketError + // cause is discarded upstream before it reaches us). + 'terminated', + 'Premature close', + // pi-ai's per-provider throws when the wire closes before the terminal event. + 'Anthropic stream ended before message_stop', + 'OpenAI Responses stream ended before a terminal response event', + 'openrouter stream ended without a terminal event', + 'Stream ended without finish_reason', ])('maps pi-ai transport wording %j', (errorMessage) => { expect(mapStopReason(assistant({ stopReason: 'error', errorMessage }))) .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) From bdb3e4da0e57f073ed645e96bf84d60cdc453671 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:18 +0800 Subject: [PATCH 04/16] test(fs): cover read result decline paths --- packages/fs/tool-fs/src/read.ts | 12 +++++++- packages/fs/tool-fs/tests/tools.spec.ts | 37 ++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 4e299d9a79..05e1b41ae2 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -154,6 +154,16 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return outcome }, + presentResult(_args, result: ToolResult): GenericResultView | undefined { + if (result.isError) return undefined + const only = result.content.length === 1 ? result.content[0] : undefined + const text = only?.type === 'text' ? only.text : undefined + if (text === undefined) return undefined + // Group 1 always captures (possibly empty) when the envelope matches. + const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] + if (body === undefined) return undefined + return { card: 'generic', content: [{ type: 'text', text: body }] } + }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the // read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 8f93b524a9..de844dcaf4 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -10,7 +10,7 @@ import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolResult } from '@deepseek-ai/dsh-tools' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -432,6 +432,11 @@ describe('tool-owned presentation (pure presentCall)', () => { return ctx.tools.get(name)?.presentCall?.(args) } + const presentResult = async (name: string, args: unknown, result: ToolResult) => { + const { ctx } = await setup() + return ctx.tools.get(name)?.presentResult?.(args, result) + } + it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => { expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({ card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', @@ -445,6 +450,36 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) + it('read: completed presentation removes the model-facing XML envelope', async () => { + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], + isError: false, + })).toEqual({ + card: 'generic', + content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }], + }) + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: 'malformed replay' }], + isError: false, + })).toBeUndefined() + }) + + it('read: completed presentation declines errors and non-single-text content', async () => { + const envelope = '/tmp/a.txt\nfile\n\nbody\n' + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }], + isError: true, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }], + isError: false, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.txt' }, { + content: [{ type: 'reasoning', text: envelope }], + isError: false, + })).toBeUndefined() + }) + it('read: "from line N" window when only offset is set', async () => { expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({ card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }], From 8452ff222b7db5c45bba2ace73f74bb5c8a43358 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:18 +0800 Subject: [PATCH 05/16] fix(ci): link session query tool catalog dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3aa3d6998a..8d06df4d63 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ }, "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", From 5d2329e2073edeae45cfa2633c2d7cbbffea29f1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:31 +0800 Subject: [PATCH 06/16] feat(skills): bundle and harden personal maintenance workflows --- ...sonal-staging-maintenance-skills.i18n.yaml | 6 +++ ...-23-personal-staging-maintenance-skills.md | 35 ++++++++++++++ ...-personal-staging-maintenance-skills.zh.md | 35 ++++++++++++++ docs/config-catalog.md | 35 +++++++++----- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 5 +- docs/core-data-structures/skills.zh.md | 5 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/skill/skill-local/src/index.ts | 25 +++++++--- .../skill-local/tests/skill-local.spec.ts | 42 +++++++++++++++-- packages/skill/skill/src/index.ts | 2 +- scripts/verify-md-links.ts | 1 + scripts/verify-mermaid.ts | 1 + skills/dsh-customize/SKILL.md | 35 ++++++++++++++ skills/dsh-upgrade/SKILL.md | 46 +++++++++++++++++++ skills/dsh-upstream-customization/SKILL.md | 27 +++++++++++ 16 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md create mode 100644 .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md create mode 100644 skills/dsh-customize/SKILL.md create mode 100644 skills/dsh-upgrade/SKILL.md create mode 100644 skills/dsh-upstream-customization/SKILL.md diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml new file mode 100644 index 0000000000..b27ba457af --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.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 +2026-07-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 +2026-07-23-personal-staging-maintenance-skills.zh.md: db1595c83da0ad93e9ba9055b5a3d7fe7cfe1706 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md new file mode 100644 index 0000000000..a7ccc5b1e0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md @@ -0,0 +1,35 @@ +# Agent Note: Personal staging maintenance skills + +Status: implemented + +English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) + +## Problem + +Personal dsh customizations need a repeatable way to locate the installed source, isolate task work, serialize integration, and incorporate upstream changes without rewriting the checkout used by running sessions. User-local instructions solve this for one installation but cannot guide other users or remain synchronized with repository installer behavior. + +## Decision + +The repository distributes [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md), [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md), and [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md) from its root `skills/` directory. Their descriptions name both the operation and user requests that select it. The shipped TUI supplies that directory to the local skill provider at startup, below project and user roots in discovery priority. The workflows derive the active checkout and staging branch from the installed launcher rather than a user-specific path or branch name, defer to repository-local instructions, require task worktrees, and serialize staging mutations with the staging worktree's established `.agents/merge.lock`. + +Before rebasing, an upgrade inspects the Git log and commit ranges to identify incoming upstream changes, personal commits, duplicates, and likely conflicts. It drops customizations already supplied upstream; when only a documentary local diff remains for such a customization, it also drops that account unless it adds an independently useful current contract absent upstream. Each attempt uses one UTC basic timestamp for its independent `dsh-staging-` sibling clone, local `dsh-upgrade/prepare-` branch, new `dsh-staging/` branch, private upstream and recovery refs, and launcher backup. The sibling name does not derive from the current directory name, and collisions fail rather than acquiring ad hoc suffixes. The workflow derives the current DSH process source from the process command and runtime environment rather than the shell working directory, then treats the repository and checkout behind the installed launcher as immutable except for holding its existing merge lock. + +After validation in the independent clone, the workflow creates and verifies the timestamped staging branch, then atomically moves the launcher once from the unchanged old staging checkout to the new staging checkout. The launcher never targets a preparation, feature, review, publication, or detached checkout. Failure before cutover leaves the installed checkout and launcher unchanged; failure after cutover restores and verifies the launcher backup. The old staging checkout, its branch, the recovery ref, and the launcher backup remain available until a restarted process proves that DSH runs from the new staging branch and the user explicitly approves rollback cleanup. + +`dsh-upstream-customization` owns upstream publication independently from local maintenance and upgrades. It recommends bug fixes, additive non-conflicting plugin features, and visual improvements; intrusive changes require maintainer approval first. At the end of an upgrade, the agent classifies remaining customizations, explains their upstream value, recommends whether to propose each one, and asks which named candidate the user wants to upstream. Only that selection loads the publication workflow; each feature still requires explicit approval before a push or draft PR. Approved changes start from current upstream `master` without unrelated personal commits. Draft PRs for TUI features preferably include a screenshot from the assembled application after credentials and personal data are removed. `dsh-customize` requires interactive TUI behavior to be exercised in a dedicated tmux session before integration. + +## Alternatives considered + +**Keep the workflows user-scoped.** This preserves personal flexibility but prevents other users from discovering the same safety rules and lets the workflow drift from the installer shipped by the repository. + +**Rebase the active staging checkout in place.** This is simpler but changes many files during preparation, can disrupt new dsh launches, and cannot provide atomic publication or an unchanged rollback checkout. + +**Update the existing staging checkout after moving the launcher elsewhere.** This retains one staging path but requires a mid-upgrade launcher target that is not a staging branch and still rewrites a checkout that may host a running process. + +**Lock only the final branch switch.** This shortens lock duration but permits a customization merge against the old base while the rebase is being prepared, invalidating the prepared history. + +**Open one upstream PR for all personal changes.** This reduces branch management but publishes unrelated customizations and removes the user's per-feature approval boundary. + +## Consequences + +Upgrade preparation holds the installed staging merge lock while dependencies and checks run, so local customization integration waits for a consistent result. One upgrade creates an independent timestamped clone and staging branch, performs one atomic launcher cutover, and requires one restart afterward; it never writes into the repository or checkout behind the launcher except to hold its existing lock. Each workflow records preconditions, repeats them before mutation, inspects state after interrupted mutations, restores the launcher backup on cutover failure, reruns failed checks after correction, and reports final state. The old staging checkout remains rollback storage until explicit user-approved cleanup. Checked-in evaluations cover selection, process-source protection, unsafe repository states, rollback, and publication authorization; repository documentation checks validate skill links and formatting, while technical review remains responsible for Git and filesystem correctness. diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md new file mode 100644 index 0000000000..db1595c83d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 个人集成分支维护 skill(技能) + +Status: implemented + +[English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 + +## 问题 + +个人 dsh 定制需要一套可重复执行的方法,用于定位已安装的源码、隔离各项任务的修改、串行集成变更,并在不改写运行中会话所用检出的前提下合入上游变更。用户本地指令能解决某一套安装中的问题,却无法指导其他用户,也无法持续与仓库安装脚本的行为保持同步。 + +## 决策 + +仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill 的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 + +升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩说明性差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前契约。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-` 同级克隆、本地 `dsh-upgrade/prepare-` 分支、新的 `dsh-staging/` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 + +在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 + +`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上完整应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 + +## 备选方案 + +**将这些工作流限定在用户本地。** 这样可以保留个人使用的灵活性,但其他用户无法发现同一套安全规则,工作流也可能逐渐偏离仓库分发的安装脚本行为。 + +**在当前集成分支检出中原地变基。** 此方案更简单,但准备期间会修改大量文件,可能干扰新的 dsh 启动,也无法实现原子发布或提供一份保持不变的回滚检出。 + +**在将启动器迁往别处后更新现有的集成分支检出。** 此方案可以保留单一的集成分支路径,却要求在升级中途让启动器指向一个并非集成分支的目标,且仍会改写可能承载运行中进程的检出。 + +**只在最终切换分支时加锁。** 这样可以缩短持锁时间,却允许写入方在变基准备期间继续基于旧基线合并定制变更,导致准备好的历史失效。 + +**用一个上游 PR 发布所有个人变更。** 这会减少分支管理工作,却会发布无关的定制,并取消用户按功能逐项批准的边界。 + +## 影响 + +升级准备流程在安装依赖和运行检查期间持有已安装集成分支的合并锁,因此本地定制的集成必须等待一致的结果。一次升级会创建独立的带时间戳的克隆和集成分支,执行一次原子的启动器切换,并在切换后要求重启一次;除持有其既有锁之外,升级绝不会写入启动器所指向的仓库或检出。各工作流会记录前置条件、在修改前重复检查、在修改被中断后检查状态、在切换失败时恢复启动器备份、修复后重新运行失败的检查,并报告最终状态。旧的集成分支检出会作为回滚存储一直保留,直到用户明确批准清理为止。仓库内评估覆盖 skill 选择、进程源码保护、不安全的仓库状态、回滚和发布授权;仓库文档检查会验证 skill 的链接和格式,Git 与文件系统操作的正确性仍由技术评审负责。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fb96ff101a..c45cb73de3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1165,10 +1165,12 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + bundledSkillDir?: string } ``` -Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1724,7 +1726,7 @@ Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` · `tuiPrompt` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1770,21 +1772,30 @@ export interface TuiConfig { fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean - /** Apply the built-in ANSI color palette. */ - color?: boolean - /** - * Paint the startup banner's product name in the DeepSeek brand gradient - * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the - * flat accent color when either is off. Unset auto-detects `COLORTERM` at the - * process boundary, so most deployments leave it unset. - */ - truecolor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string } + +/** Theme and prompt-template settings for the pi-tui terminal mode. */ +export interface TuiThemeConfig { + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Paint the startup banner with the 24-bit DeepSeek brand gradient. */ + truecolor?: boolean + /** Left-aligned template on the row above the editor. */ + leftPrompt?: string + /** Right-aligned template on the row above the editor. */ + rightPrompt?: string + /** Template used as the editor's first-line prefix. */ + inputPrompt?: string + /** Static placeholder shown in an empty editor while the agent is running. */ + inputPlaceholder?: string +} ``` -Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:302`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..4d07a8d8b1 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.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 -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +skills.md: 0cd0bc56f50e49e4cfde0e41394b42bc93399f69 +skills.zh.md: 732cdffc83b3ba13ecb498f5eafed511da71df36 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..0cd0bc56f5 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -47,6 +47,7 @@ The shipped local provider scans roots in rank order: | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | `Config.bundledSkillDir` when configured | The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. @@ -56,7 +57,7 @@ Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider ac ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## Summaries, candidates, and complete definitions @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound. ```ts type-equiv /** Skill registry configuration. */ diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..732cdffc83 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -47,6 +47,7 @@ interface SkillProvider { | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 @@ -56,7 +57,7 @@ skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方 ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## 摘要、候选项与完整定义 @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`)。消费方拥有其目录描述上限。 ```ts type-equiv /** Skill registry configuration. */ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ca1fec1dc5..a47d6e337b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2205,7 +2205,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillSource', - declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});', + declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | \'bundled\' | (string & {});', }, { name: 'SkillSummary', diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 2c5e480e2a..02478a7244 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -32,6 +32,7 @@ const PROJECT_AGENTS_RANK = 200 const CUSTOM_RANK = 300 const USER_DSH_RANK = 400 const USER_AGENTS_RANK = 500 +const BUNDLED_RANK = 600 export const name = 'skill-local' export const inject = ['skills'] @@ -44,12 +45,15 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + bundledSkillDir?: string } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), + bundledSkillDir: z.string(), }) interface SkillRoot { @@ -57,6 +61,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + trustedHost?: boolean } interface SkillRootEntry { @@ -91,11 +96,14 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] + private readonly bundledSkillDir: string | undefined constructor(private readonly ctx: Context, config: Config = {}) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR + this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } /** @@ -122,7 +130,7 @@ export class LocalSkillProvider implements SkillProvider { */ async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise { const locator = candidate.locator as LocalLocator - const parsed = await parseSkillFile(locator.path, this.ctx, options.signal) + const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled') if (parsed === undefined) return undefined return { name: parsed.name, @@ -151,6 +159,9 @@ export class LocalSkillProvider implements SkillProvider { ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })), { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true }, { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK }, + ...this.bundledSkillDir === undefined + ? [] + : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], ) return roots } @@ -167,7 +178,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise { const fs = optionalFileSystem(ctx) - if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs) + if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs) return await listSkillRootEntriesFromNode(root, ctx) } @@ -225,8 +236,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom return result } -async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise { - const raw = await readSkillText(ctx, path, signal) +async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise { + const raw = await readSkillText(ctx, path, signal, trustedHost) signal?.throwIfAborted() if (raw === undefined) { return undefined @@ -266,10 +277,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined { return ctx.get('fs') } -async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise { +async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise { signal?.throwIfAborted() const fs = optionalFileSystem(ctx) - if (fs !== undefined) { + if (fs !== undefined && !trustedHost) { return await readSkillTextFromFileSystem(ctx, fs, path, signal) } try { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 0c7d473b13..fc3f83f955 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -149,15 +149,23 @@ describe('LocalSkillProvider', () => { await writeSkill(custom, 'custom-only', 'custom only') await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system') - const ctx = await setupLocal(home, { customSkillDirs: [custom] }) + const bundled = await tempDir('skill-bundled') + await writeSkill(bundled, 'bundled-only', 'bundled skill') + await writeSkill(bundled, 'same', 'bundled skill') + const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled }) const skills = await ctx.skills.list({ cwd: join(project, 'src') }) - expect(skills.map(skill => [skill.name, skill.description])).toEqual([ - ['custom-only', 'custom only'], - ['same', 'project dsh skill'], + expect(skills.map(skill => skill.name)).toEqual([ + 'bundled-only', + 'custom-only', + 'same', ]) + expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only') + expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill') expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh') expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined() + expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' }) + expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.') const noGit = await tempDir('skill-no-git') await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root') @@ -335,6 +343,20 @@ describe('LocalSkillProvider', () => { ]) expect(fs.listDirCalls).toBeGreaterThan(0) expect(await ctx.skills.get('binary-skill')).toBeUndefined() + + const bundled = await tempDir('skill-backend-bundled') + await writeSkill(bundled, 'bundled-host', 'Bundled host skill') + const bundledCtx = new Context() + await bundledCtx.plugin(TestFileSystem) + const bundledFs = bundledCtx.fs as TestFileSystem + bundledFs.failResolvePaths.add(bundled) + await bundledCtx.plugin(SkillService) + await bundledCtx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + bundledSkillDir: bundled, + }) + expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled') }) it('forwards cancellation to filesystem reads while loading a skill', async () => { @@ -375,17 +397,22 @@ describe('LocalSkillProvider', () => { it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME + const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR const envHome = await tempDir('skill-env-home') try { process.env.DSH_HOME = join(envHome, '.dsh') process.env.DSH_AGENTS_HOME = join(envHome, '.agents') + const bundled = join(envHome, 'bundled-skills') + process.env.DSH_BUNDLED_SKILL_DIR = bundled await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') + await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill') const ctx = new Context() await ctx.plugin(SkillService) await ctx.plugin(SkillLocal) - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill']) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') + delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) @@ -405,6 +432,11 @@ describe('LocalSkillProvider', () => { } else { process.env.DSH_AGENTS_HOME = previousAgentsHome } + if (previousBundledSkillDir === undefined) { + delete process.env.DSH_BUNDLED_SKILL_DIR + } else { + process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir + } } }) }) diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f736fc8d0f..d0b547ed8b 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean { } /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) /** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ export type SkillResourceBase = diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 191a4c9c92..969b0f5e44 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -25,6 +25,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] /** A broken relative link: a target path that does not resolve to a file. */ diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 79e80d802d..0cdf8982a4 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -26,6 +26,7 @@ const PATTERNS = [ 'AGENTS.md', 'packages/AGENTS.md', '.agents/skills/**/*.md', + 'skills/**/*.md', ] interface Block { diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md new file mode 100644 index 0000000000..74da68b578 --- /dev/null +++ b/skills/dsh-customize/SKILL.md @@ -0,0 +1,35 @@ +--- +name: dsh-customize +description: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly. +--- + +# DSH Customize + +Make personal DSH changes in task worktrees and integrate them under the staging lock. Repository instructions still apply. + +## Find staging + +Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. + +1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. +2. Follow the launcher through the full symlink chain to identify the source checkout. The standard [`scripts/install.sh`](../../scripts/install.sh) keeps every checkout under one container `${DSH_SOURCE}` (default `~/.dsh/source`): the master clone at `${DSH_SOURCE}/master` and each staging checkout as a git worktree `${DSH_SOURCE}/staging-`. `${DSH_BIN_DIR}/dsh` links to `${DSH_SOURCE}/current/bin/dsh`, and the stable `current` symlink points at the active staging worktree, so resolve `current` to reach the real checkout. All paths are configurable; an older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones — follow the launcher rather than assuming a layout. +3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. +4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the master clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the master clone, or a non-staging branch. + +## Customize + +1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. +2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. +3. For TUI or interactive behavior, test the assembled application interactively in a dedicated tmux session; unit tests and snapshots alone are insufficient. +4. Record the task tip and confirm the task worktree is clean before integration. + +## Integrate under the lock + +1. Resolve the worktree that owns staging and use `/.agents/merge.lock`. Keep it Git-ignored; never remove or replace it. Require `flock`. +2. Acquire the lock, then re-check branch ownership, exact staging tip, clean status, and absence of an in-progress Git operation. If staging moved, unlock and restart discovery against its current owner's lock. +3. Hold the same lock through final precondition checks, `git merge --no-ff`, required post-merge checks, conflict handling, and rollback. +4. If the merge or a post-merge check fails, abort the merge or restore the recorded clean staging tip before unlocking. Never discard unknown user files. +5. Before unlocking, verify staging's branch, commit, clean status, and required checks. Report that evidence and the commands run. +6. Remove the task worktree and branch only when their commits are reachable from staging and no longer needed. + +Use [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) when the user wants to contribute a personal feature upstream. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md new file mode 100644 index 0000000000..7570672cc8 --- /dev/null +++ b/skills/dsh-upgrade/SKILL.md @@ -0,0 +1,46 @@ +--- +name: dsh-upgrade +description: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH. +--- + +# DSH Upgrade + +Prepare and validate the upgrade in a fresh staging worktree of the master clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. + +## Layout + +A source-installed DSH keeps every checkout under one container directory `` (default `~/.dsh/source`): the master clone at `/master` (remote tracking `master`, the fetch/upgrade base, never a launcher target) and each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`. The stable symlink `/current` points at the active staging worktree, and the PATH launcher links to `/current/bin/dsh`, so the launcher resolves PATH -> `current` -> staging worktree. Cutover repoints `current` alone; the PATH launcher is written once at install and never moves. All worktrees share the master clone's single `.git` object store; the master clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. An older install may link PATH straight at a worktree (no `current`) or use scattered sibling clones; if so, follow the recorded launcher checkout rather than assuming this layout, treat that sibling clone as its own master, and create `current` and repoint PATH to `current/bin/dsh` as a one-time migration at cutover. + +## Names + +One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all names: + +- new staging worktree: `/staging-`; +- preparation branch: `dsh-upgrade/prepare-`; +- installed staging branch: `dsh-staging/`; +- fetched upstream ref: `refs/dsh-upgrade/upstream-`; +- recovery ref: `refs/dsh-upgrade/recovery-`; +- recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. + +The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this master clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. + +## Upgrade + +1. Resolve the installed launcher, its staging worktree and branch, the master clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. +2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared master clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. +3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. +4. In the master clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the master clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. +5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. +8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared master exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. +9. Recheck the old worktree, existing lock, launcher, `current`, master clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the master clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. +10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. +11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. +12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the master clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. + +The installed launcher always resolves through `current` to a staging worktree, never the master clone. Upgrade preparation adds a new worktree that shares the master object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. + +## Recommend upstream candidates + +After a successful upgrade, load [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) and classify each remaining personal customization by its rules. For each candidate, explain its classification and upstream value and recommend whether to propose it, then ask which named candidate, if any, the user wants to upstream. The answer selects a candidate to start that skill's publication workflow; it is not publishing approval, which that workflow still requires. diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md new file mode 100644 index 0000000000..944f4b083c --- /dev/null +++ b/skills/dsh-upstream-customization/SKILL.md @@ -0,0 +1,27 @@ +--- +name: dsh-upstream-customization +description: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing. +--- + +# DSH Upstream Customization + +Classify and propose personal customizations upstream one feature at a time. + +## Classify + +- **Definitely propose:** bug fixes. +- **Propose:** additive, non-conflicting features implemented as plugins; visual improvements. +- **Do not propose without maintainer approval:** intrusive changes that alter existing architecture, core behavior, or broad contracts. +- Explain the classification and upstream value. If unsure whether a change is intrusive, treat it as intrusive. + +Classification and a recommendation are not publishing approval. Obtain explicit user approval naming one feature before pushing or opening a PR; approval for another feature, an upgrade, or local integration does not apply. + +## Publish an approved feature + +1. Fetch current upstream `master`, then rebuild only the approved feature on a fresh branch and worktree at that exact commit. Never publish the personal staging branch or unrelated customizations. +2. Follow repository instructions for implementation, review, testing, disclosure, PR writing, and pre-push checks. Fix failures and rerun the affected checks before publishing. +3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. +4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. +5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. +6. For a TUI feature, preferably attach a screenshot from the assembled application after removing credentials and personal data. +7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL. From 4c5f92e0fd6b5dbe411b93d2f999500b19d70da1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:40 +0800 Subject: [PATCH 07/16] feat(tui): personal TUI rework, integrating upstream model reasoning-effort selection Consolidates the personal dsh-tui customizations (module split into components/session/extension, prompt template + running-glyph indicator, copyable transcript, tool-card headers, timing placement, XML tool output, status/footer rework) and ports upstream's model reasoning-effort selector (Shift+Tab effort cycling, effort-aware /model, footer, and /status) onto the personal module layout. --- ...-07-23-tui-generic-card-markdown.i18n.yaml | 6 + .../2026-07-23-tui-generic-card-markdown.md | 29 + ...2026-07-23-tui-generic-card-markdown.zh.md | 29 + ...tui-turn-end-stop-reason-notices.i18n.yaml | 6 + ...-07-24-tui-turn-end-stop-reason-notices.md | 27 + ...-24-tui-turn-end-stop-reason-notices.zh.md | 27 + ...ol-card-single-row-fields-inline.i18n.yaml | 6 + ...7-27-tool-card-single-row-fields-inline.md | 23 + ...7-tool-card-single-row-fields-inline.zh.md | 23 + ...-diff-card-redundant-path-header.i18n.yaml | 6 + ...-27-tui-diff-card-redundant-path-header.md | 37 + ...-tui-diff-card-redundant-path-header.zh.md | 37 + ...ui-step-timing-trails-tool-cards.i18n.yaml | 6 + ...07-27-tui-step-timing-trails-tool-cards.md | 28 + ...27-tui-step-timing-trails-tool-cards.zh.md | 28 + ...cated-full-screen-tui-front-door.i18n.yaml | 6 +- ...17-dedicated-full-screen-tui-front-door.md | 4 +- ...dedicated-full-screen-tui-front-door.zh.md | 4 +- ...26-07-21-tui-skill-slash-command.i18n.yaml | 4 +- .../2026-07-21-tui-skill-slash-command.md | 2 +- .../2026-07-21-tui-skill-slash-command.zh.md | 2 +- ...7-23-tui-footer-session-identity.i18n.yaml | 6 + .../2026-07-23-tui-footer-session-identity.md | 27 + ...26-07-23-tui-footer-session-identity.zh.md | 27 + ...26-07-23-tui-status-prompt-tools.i18n.yaml | 6 + .../2026-07-23-tui-status-prompt-tools.md | 29 + .../2026-07-23-tui-status-prompt-tools.zh.md | 29 + ...24-configurable-tui-prompt-theme.i18n.yaml | 6 + ...026-07-24-configurable-tui-prompt-theme.md | 39 + ...-07-24-configurable-tui-prompt-theme.zh.md | 39 + ...6-07-24-readable-xml-tool-output.i18n.yaml | 6 + .../2026-07-24-readable-xml-tool-output.md | 27 + .../2026-07-24-readable-xml-tool-output.zh.md | 27 + ...4-tui-banner-model-deduplication.i18n.yaml | 6 + ...26-07-24-tui-banner-model-deduplication.md | 33 + ...07-24-tui-banner-model-deduplication.zh.md | 33 + ...-07-24-tui-message-header-timing.i18n.yaml | 6 + .../2026-07-24-tui-message-header-timing.md | 25 + ...2026-07-24-tui-message-header-timing.zh.md | 25 + ...7-24-tui-prompt-status-indicator.i18n.yaml | 6 + .../2026-07-24-tui-prompt-status-indicator.md | 33 + ...26-07-24-tui-prompt-status-indicator.zh.md | 33 + ...07-24-tui-prompt-workspace-label.i18n.yaml | 6 + .../2026-07-24-tui-prompt-workspace-label.md | 34 + ...026-07-24-tui-prompt-workspace-label.zh.md | 34 + ...26-07-24-tui-shell-prompt-editor.i18n.yaml | 6 + .../2026-07-24-tui-shell-prompt-editor.md | 33 + .../2026-07-24-tui-shell-prompt-editor.zh.md | 33 + ...assistant-timing-header-trailing.i18n.yaml | 6 + ...-07-27-assistant-timing-header-trailing.md | 25 + ...-27-assistant-timing-header-trailing.zh.md | 25 + ...27-tui-running-glyph-smooth-fade.i18n.yaml | 6 + ...026-07-27-tui-running-glyph-smooth-fade.md | 37 + ...-07-27-tui-running-glyph-smooth-fade.zh.md | 37 + .../2026-07-27-tui-tool-card-header.i18n.yaml | 6 + .../2026-07-27-tui-tool-card-header.md | 35 + .../2026-07-27-tui-tool-card-header.zh.md | 35 + ...opyable-transcript-no-gutter-bar.i18n.yaml | 6 + ...07-27-copyable-transcript-no-gutter-bar.md | 34 + ...27-copyable-transcript-no-gutter-bar.zh.md | 34 + apps/cli/src/tui.ts | 2 + docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../bash-terminal-card/terminal.expected.txt | 97 +- .../terminal.expected.txt | 102 +- .../snapshots/code-mode/terminal.expected.txt | 254 +- .../terminal.expected.txt | 177 +- .../dynamic-workflow/terminal.expected.txt | 158 +- .../terminal.expected.txt | 108 +- .../parallel-file-reads/terminal.expected.txt | 113 +- .../snapshots/todo-plan/terminal.expected.txt | 99 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 39 +- examples/tui-agent/tests/tui.snapshot.ts | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 52 - packages/examples/tui-demo/src/index.ts | 1 + .../examples/tui-demo/tests/tui-agent.spec.ts | 19 +- .../sdk/helper/src/features/builtin/app.ts | 5 + packages/sdk/helper/tests/project.spec.ts | 1 + packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/package.json | 12 +- packages/ui/tui/src/autocomplete.ts | 95 + packages/ui/tui/src/components/content.ts | 56 + packages/ui/tui/src/components/dialogs.ts | 790 ++++++ packages/ui/tui/src/components/text.ts | 49 + packages/ui/tui/src/components/theme.ts | 184 ++ packages/ui/tui/src/components/transcript.ts | 529 ++++ packages/ui/tui/src/config.ts | 213 ++ .../src/{ => extension}/overlay-manager.ts | 6 +- .../src/{extension.ts => extension/types.ts} | 2 +- packages/ui/tui/src/index.ts | 2349 ++++------------- packages/ui/tui/src/prompt.ts | 217 ++ packages/ui/tui/src/session/timing.ts | 357 +++ packages/ui/tui/src/session/tokens.ts | 96 + packages/ui/tui/src/skill-invocation.ts | 67 + packages/ui/tui/src/xml-tool-output.ts | 138 + packages/ui/tui/tests/extension.spec.ts | 4 +- packages/ui/tui/tests/harness.ts | 17 +- packages/ui/tui/tests/plugin-shape.spec.ts | 1 + packages/ui/tui/tests/prompt.spec.ts | 169 ++ .../tui/tests/session-reference.snapshot.ts | 9 +- .../advanced-cards-collapsed.expected.txt | 146 +- .../advanced-cards-expanded.expected.txt | 174 +- .../snapshots/banner-gradient.expected.txt | 33 +- .../snapshots/code-mode-pending.expected.txt | 56 +- .../conversation-streaming.expected.txt | 71 +- .../cordis-tools-pending.expected.txt | 72 +- .../snapshots/disposed-terminal.expected.txt | 121 +- .../dynamic-workflow-pending.expected.txt | 64 +- .../snapshots/errors-and-help.expected.txt | 120 +- .../snapshots/file-autocomplete.expected.txt | 37 +- .../model-effort-switching.expected.txt | 42 - .../snapshots/model-selector.expected.txt | 39 +- .../snapshots/model-switching.expected.txt | 39 +- ...question-dialog-single-option.expected.txt | 40 + .../question-dialog-validation.expected.txt | 11 +- .../snapshots/question-dialog.expected.txt | 19 +- .../snapshots/retry-cancelled.expected.txt | 48 +- .../snapshots/retry-exhausted.expected.txt | 44 +- .../snapshots/retry-recovered.expected.txt | 48 +- .../snapshots/retry-scheduled.expected.txt | 44 +- .../snapshots/session-reference.expected.txt | 52 +- .../shell-prompt-multiline.expected.txt | 31 + .../status-diagnostics-narrow.expected.txt | 102 +- .../snapshots/status-diagnostics.expected.txt | 94 +- .../step-timing-completed.expected.txt | 34 + ...rface-after-compaction-narrow.expected.txt | 46 +- ...surface-after-compaction-wide.expected.txt | 44 +- .../surface-before-compaction.expected.txt | 84 +- .../snapshots/untrusted-controls.expected.txt | 110 +- packages/ui/tui/tests/tui.snapshot.ts | 259 +- packages/ui/tui/tests/tui.spec.ts | 836 ++++-- packages/ui/tui/tests/xml-tool-output.spec.ts | 105 + packages/ui/tui/tsdown.config.ts | 7 + patches/@earendil-works__pi-tui@0.80.7.patch | 346 +++ pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 3 + scripts/check-workspace-constraints.ts | 1 + 139 files changed, 7491 insertions(+), 3792 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md create mode 100644 .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md create mode 100644 .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md create mode 100644 packages/ui/tui/src/autocomplete.ts create mode 100644 packages/ui/tui/src/components/content.ts create mode 100644 packages/ui/tui/src/components/dialogs.ts create mode 100644 packages/ui/tui/src/components/text.ts create mode 100644 packages/ui/tui/src/components/theme.ts create mode 100644 packages/ui/tui/src/components/transcript.ts create mode 100644 packages/ui/tui/src/config.ts rename packages/ui/tui/src/{ => extension}/overlay-manager.ts (98%) rename packages/ui/tui/src/{extension.ts => extension/types.ts} (99%) create mode 100644 packages/ui/tui/src/prompt.ts create mode 100644 packages/ui/tui/src/session/timing.ts create mode 100644 packages/ui/tui/src/session/tokens.ts create mode 100644 packages/ui/tui/src/skill-invocation.ts create mode 100644 packages/ui/tui/src/xml-tool-output.ts create mode 100644 packages/ui/tui/tests/prompt.spec.ts delete mode 100644 packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt create mode 100644 packages/ui/tui/tests/xml-tool-output.spec.ts create mode 100644 packages/ui/tui/tsdown.config.ts create mode 100644 patches/@earendil-works__pi-tui@0.80.7.patch diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml new file mode 100644 index 0000000000..98dfa90c4d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.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 +2026-07-23-tui-generic-card-markdown.md: 494ba580480fa99de54e84025a65bf4589d410f2 +2026-07-23-tui-generic-card-markdown.zh.md: 214edd00f50b88a4e8901b19dcdafc7382399831 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md new file mode 100644 index 0000000000..494ba58048 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md @@ -0,0 +1,29 @@ +# Agent Note: TUI generic-card Markdown rendering + +Status: implemented + +English | [中文](2026-07-23-tui-generic-card-markdown.zh.md) + +## Problem + +Tool presenters can put Markdown in generic-card content, including fenced `console` output used for background-task acknowledgements and execution errors. Rendering that content as plain text exposes the fence markers and diverges from assistant and user content in the same transcript. + +## Decision + +The TUI renders generic-card result content with its shared Markdown theme before applying the card's head-and-tail line limit. Terminal and diff cards retain their specialized plain-text renderers, and generic-card raw input remains literal because it represents tool arguments rather than presenter-authored prose. + +The shared theme hides fence syntax, retains the optional language label, and colors the fenced body as code. Rendering precedes truncation so collapsed-card line counts and boundaries describe the visible terminal rows rather than Markdown source rows. + +## Alternatives considered + +**Strip fences in the Bash presenter.** This would fix one producer while leaving generic-card Markdown from other tools unrendered and would make the presenter depend on TUI behavior. + +**Render every tool card as Markdown.** Terminal output and diffs have dedicated formatting and may contain Markdown punctuation that must remain literal. + +**Apply the collapsed-card limit before Markdown rendering.** Source-line truncation can split a fenced block and makes the visible line count differ from the count used by the card. + +## Consequences + +Generic tool cards use the same Markdown vocabulary and sanitization path as conversation content. Markdown punctuation in a generic card is interpreted rather than always displayed literally; tools that require literal terminal output use the terminal card intent. + +The focused TUI test pins hidden fences, retained language labels, and body text. The keyless terminal-state snapshot covers the behavior through an assembled TUI transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md new file mode 100644 index 0000000000..214edd00f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 通用卡片的 Markdown 渲染 + +Status: implemented + +[English](2026-07-23-tui-generic-card-markdown.md) | 中文 + +## Problem + +工具展示器可以在通用卡片(generic card)内容中写入 Markdown,其中包括用于后台任务确认和执行错误的围栏 `console` 输出。把这些内容按纯文本渲染会暴露围栏标记,并与同一 transcript(文本记录)中的助手内容和用户内容显示不一致。 + +## Decision + +TUI 先用共享的 Markdown 主题渲染通用卡片的结果内容,再应用卡片的头尾行数限制。终端卡片和 diff 卡片保留各自专门的纯文本渲染器;通用卡片的原始输入仍按字面显示,因为它代表的是工具参数,而非展示器撰写的行文。 + +共享主题隐藏围栏语法,保留可选的语言标签,并将围栏正文按代码配色。渲染先于截断执行,因此收起状态卡片的行数和边界描述的是可见的终端行,而非 Markdown 源文本行。 + +## Alternatives considered + +**在 Bash 展示器中剥除围栏。**这只修复一个生产方,其他工具产生的通用卡片 Markdown 仍不会被渲染,还会让展示器依赖 TUI 的行为。 + +**把每种工具卡片都按 Markdown 渲染。**终端输出和 diff 有专门的格式,且可能包含必须保持字面显示的 Markdown 标点。 + +**在 Markdown 渲染之前应用收起状态卡片的行数限制。**按源文本行截断可能从中间截断围栏块,还会让可见行数与卡片使用的行数不一致。 + +## Consequences + +通用工具卡片与对话内容使用同一套 Markdown 词汇和净化路径。通用卡片中的 Markdown 标点会被解释,而不再总是按字面显示;需要字面终端输出的工具使用终端卡片这一渲染意图。 + +聚焦的 TUI 测试固定了隐藏的围栏、保留的语言标签和正文文本。无密钥的终端状态快照通过组装后的 TUI transcript 覆盖该行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml new file mode 100644 index 0000000000..6d83cc0cf3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.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 +2026-07-24-tui-turn-end-stop-reason-notices.md: 7c783ce5a347b15d682dbeca03ad5355aca7950b +2026-07-24-tui-turn-end-stop-reason-notices.zh.md: 4a983525779b96c296ac2d621ca5928e7bed61c9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md new file mode 100644 index 0000000000..7c783ce5a3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md @@ -0,0 +1,27 @@ +# Agent Note: TUI presents a reason for every turn-end kind + +Status: implemented + +English | [中文](2026-07-24-tui-turn-end-stop-reason-notices.zh.md) + +## Problem + +The TUI rendered transcript notices for `error`, `aborted`, `max-tokens`, `rejected`, and `interrupted` turn ends, but a `disposed` turn end and any plugin-added `TurnEndReasonMap` kind rendered nothing. When such a turn ended — live or replayed from a persisted log — the agent stopped working with no visible reason, breaking the product expectation that every stop is explained to the user. + +## Decision + +The `turn/end` case in `packages/ui/tui/src/index.ts` switches on the reason's discriminant and covers every kind: `completed` stays silent because the settled assistant message and its `Completed` timing header already present that outcome; `disposed` appends `Turn stopped: the agent was disposed.`; and the merge-extensible default appends `Turn ended: .` so an unknown plugin-added outcome still names why the agent stopped. All other kinds keep their existing notices. + +## Alternatives considered + +**A notice for `completed` turns too.** Rejected as noise: every ordinary response would gain a redundant line, and the assistant message plus its frozen timing header already mark the completion. + +**Suppressing the `disposed` turn-end notice live because `agent/disposed` also appends `Agent "" was disposed.`** Rejected: the two notices state different facts (this turn was cut short vs. the agent is gone), and the turn-end notice is the only one that survives replay of a persisted log, where the live `agent/disposed` emission does not recur. + +**Keeping the default branch silent (the prior behavior).** Rejected: a merge-extensible kind unknown to the TUI is exactly the case where the user has no other way to learn why the agent stopped. + +## Consequences + +- A turn never ends without a user-visible reason in the TUI: every non-`completed` `turn/end` kind appends a transcript notice, including unknown plugin-added kinds by name. +- Live disposal during a running turn shows two notices (the turn-end notice plus `agent/disposed`); a replayed log shows the turn-end notice alone. +- The `errors-and-help` snapshot pins the `disposed` and unknown-kind notices alongside the existing failure and interruption notices. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md new file mode 100644 index 0000000000..4a98352577 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md @@ -0,0 +1,27 @@ +# Agent Note: TUI 为每种轮次结束 kind 呈现原因 + +Status: implemented + +[English](2026-07-24-tui-turn-end-stop-reason-notices.md) | 中文 + +## 问题 + +TUI 会为 `error`、`aborted`、`max-tokens`、`rejected`、`interrupted` 这几种轮次结束渲染 transcript(文本记录)通知,但 `disposed` 轮次结束和任何插件新增的 `TurnEndReasonMap` kind 不渲染任何内容。此类轮次结束时,无论实时发生还是从持久化日志回放,agent(智能体)都会在没有任何可见原因的情况下停止工作,违背了「每次停止都要向用户解释」的产品预期。 + +## 决策 + +`packages/ui/tui/src/index.ts` 中的 `turn/end` 分支按 reason 的判别字段做 switch,覆盖每一种 kind:`completed` 保持沉默,因为已定稿的助手消息及其 `Completed` 计时头部已经呈现了这一结果;`disposed` 追加 `Turn stopped: the agent was disposed.`;merge 扩展的 default 分支追加 `Turn ended: .`,让未知的插件新增结果仍能点明 agent 停止的原因。其余各 kind 保留现有通知。 + +## 备选方案 + +**为 `completed` 轮次也加一条通知。** 否决,属于噪音:每次普通响应都会平添一行冗余内容,而助手消息加上已冻结的计时头部本就标示了完成。 + +**因为 `agent/disposed` 也会追加 `Agent "" was disposed.`,就在实时场景下抑制 `disposed` 轮次结束通知。** 否决:两条通知陈述的是不同事实(前者说明这一轮被中途截断,后者说明 agent 已不复存在),而且只有轮次结束通知在回放持久化日志时得以保留,实时发出的 `agent/disposed` 不会在回放中重现。 + +**让 default 分支保持沉默(沿用先前行为)。** 否决:TUI 不认识的 merge 扩展 kind,恰恰是用户没有其他途径得知 agent 为何停止的情形。 + +## 后果 + +- 在 TUI 中,轮次结束永远不会缺少用户可见的原因:每种非 `completed` 的 `turn/end` kind 都会追加一条 transcript 通知,未知的插件新增 kind 也会按名称列明。 +- 轮次运行期间实时 dispose(资源释放)会显示两条通知(轮次结束通知加上 `agent/disposed`);回放日志则只显示轮次结束通知。 +- `errors-and-help` 快照把 `disposed` 通知和未知 kind 通知连同现有的失败与中断通知一并固定下来。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml new file mode 100644 index 0000000000..dfde0f9ad4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.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/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md +2026-07-27-tool-card-single-row-fields-inline.md: e04110ed74b68afffc6ea45fc2cb52c4063268e7 +2026-07-27-tool-card-single-row-fields-inline.zh.md: ac532ec6eb51d42a529e0816d1204cb2729a074a diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md new file mode 100644 index 0000000000..e04110ed74 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md @@ -0,0 +1,23 @@ +# Agent Note: Tool-card single-row fields render inline + +Status: implemented + +English | [中文](2026-07-27-tool-card-single-row-fields-inline.zh.md) + +## Problem + +A tool card's title, description, cwd, and pending `$ ` echo are each one logical row. The bash tool sets the card title (and description) directly from the model's command and description, which for a multi-line bash script contain real newlines. These fields were escaped with `displayText`, which deliberately preserves `\n` as structural layout. A multi-line title therefore broke onto extra terminal rows that the card's line accounting did not reserve, so the title's later lines overwrote the description, the output, or the editor's steering hint — the card rendered as garbled, overlapping text. Removing the gutter bar (see the [copyable-transcript note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md)) made the collision visible because those rows no longer sat behind a per-line prefix. + +## Decision + +Single-row card fields use `displayInlineText` (which escapes `\n` to the literal `\x0a`) instead of `displayText`: the card title, the terminal-card `description` and `cwd` meta rows, and the pending `$ ` echo. Each stays on exactly one row, so a multi-line command can no longer break rows and collide with adjacent lines. Genuinely multi-line fields — captured command output and the `contentText` result body — keep `displayText` plus `split('\n')`, because those legitimately occupy multiple rows. + +## Alternatives considered + +- **Strip newlines from the presenter output** (in the bash tool) — hides the model's real command shape from any consumer of the view, and pushes a UI concern into the tool. The escape belongs at the single-row render site. +- **Let the title wrap to multiple rows deliberately** — a card title is a one-line identity; a wrapped multi-line title still collides with the following meta rows unless the whole card is re-laid-out, and it bloats the transcript. + +## Consequences + +- Multi-line bash commands render as a single inline title (`S=/tmp\x0aecho …`); the description, output, and exit rows below stay intact. Verified live in tmux for both the pending (`◌`) and completed (`✓`) states. +- A `multilineTerminal` tool-card case in `tui.spec.ts` asserts the inline-escaped form appears for a newline-bearing title and description. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md new file mode 100644 index 0000000000..ac532ec6eb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 工具卡片的单行字段以内联方式渲染 + +Status: implemented + +[English](2026-07-27-tool-card-single-row-fields-inline.md) | 中文 + +## Problem + +工具卡片的标题、描述、cwd 以及待执行的 `$ ` 回显各自都是一个逻辑行。bash 工具直接用模型给出的命令与描述来设置卡片标题(和描述),而对于多行 bash 脚本,这些内容包含真实换行。这些字段此前用 `displayText` 转义,而 `displayText` 会刻意保留 `\n` 作为结构性布局。于是多行标题会换到卡片行数核算未预留的额外终端行上,标题后续的行便覆盖了描述、输出,或编辑器的 steering 提示——卡片渲染成互相重叠的乱码文本。移除 gutter bar(见[可复制 transcript 的 note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md))后,这些行不再位于逐行前缀之后,因而暴露了这一冲突。 + +## Decision + +单行卡片字段改用 `displayInlineText`(将 `\n` 转义为字面量 `\x0a`)而非 `displayText`:包括卡片标题、terminal 卡片的 `description` 与 `cwd` 元数据行,以及待执行的 `$ ` 回显。每个字段都严格保持在一行内,因此多行命令不再会换行并与相邻行冲突。真正多行的字段——捕获的命令输出与 `contentText` 结果正文——仍保留 `displayText` 加 `split('\n')`,因为它们本就应占据多行。 + +## Alternatives considered + +- **在 presenter 输出中剥除换行**(在 bash 工具里)—— 会对该视图的所有消费方隐藏模型真实的命令形态,并把 UI 关注点塞进工具。转义应发生在单行渲染处。 +- **让标题刻意换到多行** —— 卡片标题是一行式身份标识;除非重排整个卡片,多行标题仍会与其后的元数据行冲突,还会让 transcript 膨胀。 + +## Consequences + +- 多行 bash 命令渲染为单行内联标题(`S=/tmp\x0aecho …`);其下的描述、输出与退出码行保持完整。已在 tmux 中对待执行(`◌`)与已完成(`✓`)两种状态实测验证。 +- `tui.spec.ts` 中新增了一个 `multilineTerminal` 工具卡片用例,断言对含换行的标题与描述会出现内联转义后的形式。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml new file mode 100644 index 0000000000..a8472075e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.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/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a +2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md new file mode 100644 index 0000000000..708e543ff0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff card dropped the duplicated file path + +Status: implemented + +English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) + +## Problem + +The `edit` and `write` tool cards printed the target path twice. Each tool's `presentCall`/`presentResult` returns a diff card whose title is `Edit `/`Write ` and whose single `FileDiff` carries the same `path`. The TUI's `diffLines` unconditionally rendered `palette.bold(diff.path)` as a per-file header, so a one-file edit rendered: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +The existing snapshot fixture hid the bug: it titled the edit card `Edit renderer` (no path) and gave the result two diffs, so the title never matched a diff path and the header never looked redundant. + +## Decision + +`diffLines` takes a `showPath` flag; `ToolCardComponent.renderBody` suppresses the per-file header for a diff card when there is exactly one diff and the effective card title (`resultView?.title ?? callView.title`) already contains that diff's path. Multi-file diff cards keep every per-file header. An empty or blank diff path collapses under the same `String.includes` check, which is the intended noise removal. + +The suppression lives in the TUI renderer, not in each tool's presenter, because the redundancy is a presentation concern shared by every current and future single-file diff card; the tools keep emitting the path in both the title and the diff so non-TUI consumers still get it. + +## Alternatives considered + +- Drop the path from the `edit`/`write` card titles. Rejected: the title is the scannable summary line; removing the path weakens it, and it would have to be repeated per tool. +- Always drop the per-file header. Rejected: multi-file result diffs (and any future multi-file diff card) genuinely need per-file headers. + +## Consequences + +The heuristic is a substring match, so a title that happens to contain a single diff's path suppresses the header even if the match is incidental; for the real producers the title is exactly `Verb `, so this is correct in practice. The snapshot `edit` fixture now mirrors production: one diff whose path the title names, proving the header is dropped, while multi-file header retention is covered by the `tui.spec.ts` `edit` fixture (`a.txt`/`b.txt` under an `Edit files` title). + +## Testing + +`tui.spec.ts` adds a focused case asserting the path appears exactly once for a single-diff card titled `Edit src/only.ts`. The `advanced-cards-*` keyless snapshots re-recorded to show the title line immediately followed by the diff body with no repeated path header. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md new file mode 100644 index 0000000000..863868ae70 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff 卡片重复打印文件路径 + +Status: implemented + +[English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 + +## Problem + +`edit` 与 `write` 工具卡片会把目标路径打印两次。两者的 `presentCall`/`presentResult` 返回的 diff 卡片,标题为 `Edit `/`Write `,而其唯一的 `FileDiff` 又携带相同的 `path`。TUI 的 `diffLines` 无条件地将 `palette.bold(diff.path)` 渲染为每文件的表头,因此单文件编辑会渲染成: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +既有的快照 fixture 掩盖了这个问题:它把编辑卡片标题设为 `Edit renderer`(不含路径),并让结果包含两个 diff,于是标题从未与某个 diff 路径匹配,表头也就不显得冗余。 + +## Decision + +`diffLines` 新增 `showPath` 参数;当一个 diff 卡片只有一个 diff、且生效标题(`resultView?.title ?? callView.title`)已包含该 diff 的路径时,`ToolCardComponent.renderBody` 抑制每文件表头。多文件 diff 卡片保留全部每文件表头。空白或空路径同样落入这条 `String.includes` 判定之下,这正是有意去除的噪声。 + +抑制逻辑放在 TUI 渲染层,而非各工具的 present 方法中,因为这种冗余是所有当前及未来单文件 diff 卡片共有的展示问题;工具仍在标题和 diff 中同时给出路径,从而非 TUI 消费方依旧能拿到它。 + +## Alternatives considered + +- 从 `edit`/`write` 卡片标题中去掉路径。已否决:标题是可快速扫读的摘要行,去掉路径会削弱它,而且需要在每个工具里重复处理。 +- 一律去掉每文件表头。已否决:多文件结果 diff(以及未来任何多文件 diff 卡片)确实需要每文件表头。 + +## Consequences + +该启发式是子串匹配,因此若标题恰好包含某个单一 diff 的路径,即便是偶然匹配也会抑制表头;对真实的产出方而言标题恰为 `Verb `,故在实践中是正确的。快照 `edit` fixture 现在与生产一致:单个 diff,其路径正是标题所命名,从而证明表头被去除;而多文件表头保留由 `tui.spec.ts` 的 `edit` fixture(`Edit files` 标题下的 `a.txt`/`b.txt`)覆盖。 + +## Testing + +`tui.spec.ts` 新增一个聚焦用例,断言标题为 `Edit src/only.ts` 的单 diff 卡片中路径恰好出现一次。`advanced-cards-*` 无密钥快照已重新录制,展示标题行紧接 diff 正文、不再有重复的路径表头。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml new file mode 100644 index 0000000000..c246c6fb0d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.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 +2026-07-27-tui-step-timing-trails-tool-cards.md: 82f46b44d3b939ca89c4508eb948ed584082d9fd +2026-07-27-tui-step-timing-trails-tool-cards.zh.md: 885b232973d20013782dee7ec1e846e7a012b01e diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md new file mode 100644 index 0000000000..82f46b44d3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md @@ -0,0 +1,28 @@ +# Agent Note: TUI step timing trails the step's last message + +Status: implemented + +English | [中文](2026-07-27-tui-step-timing-trails-tool-cards.zh.md) + +## Problem + +The per-step timing summary (`Model wait … · Completed …`) was a child of the assistant message component, so it rendered directly under the assistant text. When a step drove tool calls, the tool cards were appended to the chat *after* the assistant message, leaving the timing line stranded above them — one message before the step's actual last output. The summary is meant to close a step, so on any tool-calling step it appeared in the wrong place. + +## Decision + +The timing summary is its own `StepTimingComponent`, no longer a child of `AssistantMessageComponent`. `StreamingAssistantComponent` owns one and exposes it as `timing`, but the renderer attaches it to the chat as a sibling that follows the assistant message. Whenever a `tool/call` or `tool/result` of the open step appends a card, `trailStreamingTiming()` moves the footer back to the tail of the chat, so it always trails the step's last message. On `step/end` the footer is completed in place — already at the tail — and stays pinned while the next step's output follows. `removeStreaming` and the reasoning-toggle rebuild detach and reattach the footer together with its streaming component. + +Event ordering makes this exact: within a step the loop appends `tool/call` and `tool/result` before `step/end`, so the footer is repositioned while `streaming` is still set, then frozen when the step ends. + +## Alternatives considered + +**Keep the timing inside the assistant message and reorder tool cards above it.** Rejected: tool cards belong after the assistant text that requested them; moving them above the assistant message to sit under the timing would misrepresent the transcript order. + +**Recompute a single trailing footer for the whole turn instead of one per step.** Rejected: a multi-step turn shows each step's own completed timing, and collapsing them would drop the per-step buckets the existing timing tests pin. + +**Reposition the footer from a `step/end`-only handler.** Rejected: tool cards render before `step/end`, so a footer moved only at step end would already be trailing but would not track a mid-step re-render, and the running (pre-completion) footer would still sit above the tool cards during streaming. + +## Consequences + +- On a tool-calling step the timing summary renders below the tool cards, both while the turn runs and after it completes; the package snapshots (`untrusted-controls`, `cordis-tools-pending`, `advanced-cards-*`, `code-mode-pending`, `dynamic-workflow-pending`, `surface-before-compaction`) and the example transcripts (`todo-plan`, `bash-terminal-card`, `code-mode`, `parallel-file-reads`, `dynamic-workflow`, `cordis-dynamic-toolchain`, `code-mode-dispatch-spill`) pin the new order. +- A unit test asserts the completed timing appears after a step's tool output; it fails on the pre-fix ordering. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md new file mode 100644 index 0000000000..885b232973 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md @@ -0,0 +1,28 @@ +# Agent Note: TUI 步骤计时跟在该步骤最后一条消息之后 + +Status: implemented + +[English](2026-07-27-tui-step-timing-trails-tool-cards.md) | 中文 + +## 问题 + +每步的计时摘要(`Model wait … · Completed …`)原本是助手消息组件的子节点,因此直接渲染在助手文本下方。当某一步触发 tool call 时,tool card(工具卡片)会在助手消息*之后*追加到聊天区,使计时行被搁在它们上方——落在该步骤真正的最后一条输出之前一条消息处。该摘要本意是收束一个步骤,因此在任何含 tool call 的步骤上都出现在了错误的位置。 + +## 决策 + +计时摘要现在是独立的 `StepTimingComponent`,不再是 `AssistantMessageComponent` 的子节点。`StreamingAssistantComponent` 持有一个并以 `timing` 暴露它,但渲染器把它作为紧随助手消息之后的同级节点挂到聊天区。每当当前打开步骤的 `tool/call` 或 `tool/result` 追加一张卡片,`trailStreamingTiming()` 就把该页脚移回聊天区末尾,使它始终跟在该步骤的最后一条消息之后。在 `step/end` 时该页脚就地定稿——此时已在末尾——并在后续步骤的输出接续时保持钉住。`removeStreaming` 与推理开关重建会把该页脚连同其流式组件一起摘除并重新挂上。 + +事件顺序让这一点精确成立:在一个步骤内,循环会先追加 `tool/call` 和 `tool/result`,再追加 `step/end`,因此页脚是在 `streaming` 仍被设置时重新定位的,随后在步骤结束时冻结。 + +## 备选方案 + +**把计时保留在助手消息内部,改为把 tool card 排到它上方。** 否决:tool card 应位于请求它们的助手文本之后;把它们移到助手消息上方以贴在计时下方,会歪曲 transcript(文本记录)的顺序。 + +**为整个轮次重算一个末尾页脚,而非每步一个。** 否决:多步轮次会显示各步自己的完成计时,合并它们会丢掉现有计时测试所固定的每步分桶。 + +**只在 `step/end` 处理器里重新定位页脚。** 否决:tool card 在 `step/end` 之前渲染,因此仅在步骤结束时移动的页脚虽已处于末尾,却无法跟踪步骤中途的重新渲染,而且流式过程中运行态(完成前)的页脚仍会落在 tool card 上方。 + +## 后果 + +- 在含 tool call 的步骤上,计时摘要渲染在 tool card 下方,轮次运行期间与完成之后皆如此;相关包快照(`untrusted-controls`、`cordis-tools-pending`、`advanced-cards-*`、`code-mode-pending`、`dynamic-workflow-pending`、`surface-before-compaction`)与示例 transcript(`todo-plan`、`bash-terminal-card`、`code-mode`、`parallel-file-reads`、`dynamic-workflow`、`cordis-dynamic-toolchain`、`code-mode-dispatch-spill`)固定了新顺序。 +- 一个单元测试断言完成计时出现在某步骤的工具输出之后;在修复前的顺序下它会失败。 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 6d44658ecc..3df1059e1f 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 @@ -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-17-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede +# 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 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 8d7c7b00c8..a3f3d6b51e 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 @@ -20,9 +20,9 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -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. 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; agent behavior and answer logging remain owned by their existing services. +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. 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 49e9541c9f..6a0c2f1281 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 @@ -20,9 +20,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +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}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 40dd8f463e..6d9112d663 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.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 -2026-07-21-tui-skill-slash-command.md: d7532a05fce5605491ce42c87a2a523eb4c19acc -2026-07-21-tui-skill-slash-command.zh.md: 16930020bd404f7bc9476169cd1d063aa57b5c94 +2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 +2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index d7532a05fc..8370ab61f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -14,7 +14,7 @@ The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door o The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 16930020bd..66edec6ecd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -14,7 +14,7 @@ Status: implemented TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml new file mode 100644 index 0000000000..0573cca056 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-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 +2026-07-23-tui-footer-session-identity.md: aa17ead4194c52464de0caad86d8611eae94786c +2026-07-23-tui-footer-session-identity.zh.md: 686c11294ffd02304dc87cd790252a347fe35011 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md new file mode 100644 index 0000000000..aa17ead419 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md @@ -0,0 +1,27 @@ +# Agent Note: Keep the TUI session identity visible + +Status: implemented + +English | [中文](2026-07-23-tui-footer-session-identity.zh.md) + +## Problem + +The startup banner identifies the active session, but it scrolls out of view during a conversation. Operators working with several resumable sessions then lack a persistent way to confirm which session receives their input. + +## Decision + +The TUI footer begins with the active session id, before the model, working directory, token counts, cache rate, and context use. It shows tool-card state only while cards are expanded; the default collapsed state adds no label. The session id uses the same control-character escaping as other terminal labels and participates in the footer's existing left-to-right clipping behavior. + +The footer reads the id from the mounted agent's session, so fresh and resumed sessions use the same authoritative identity without separate UI state. + +## Alternatives considered + +- **Keep the identity only in the startup banner** — rejected because the banner leaves the viewport in longer conversations. +- **Show the session id only in `/status`** — rejected because an on-demand diagnostic does not let an operator confirm identity before sending input. +- **Put the session id in the right footer segment** — rejected because narrow terminals clip that segment first; session identity is more important than context and expanded tool-card state. + +## Consequences + +The current session remains identifiable while the editor is active. On narrow terminals, the longer left segment leaves less room for context and the expanded tool-card label, while the existing clipping policy preserves session identity, model, and as much operational context as fits. + +Package coverage pins the footer ordering and escaping path, and the runnable TUI terminal snapshots pin the assembled layout. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md new file mode 100644 index 0000000000..686c11294f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 保持 TUI 会话标识可见 + +[English](2026-07-23-tui-footer-session-identity.md) | 中文 + +Status: implemented + +## Problem + +启动横幅会标识当前会话,但在对话过程中会滚出视野。操作多个可恢复会话时,用户因而无法持续确认输入将发送到哪个会话。 + +## Decision + +TUI 页脚以当前会话 id 开头,之后依次显示模型、工作目录、token 用量、缓存命中率和上下文用量。工具卡片状态仅在卡片展开时显示;默认的折叠状态不添加任何标签。会话 id 与其他终端标签采用相同的控制字符转义,并遵循页脚现有的从左到右裁剪行为。 + +页脚从已挂载 agent 的会话读取 id,因此新建和恢复的会话都使用同一权威标识,无需单独维护 UI 状态。 + +## Alternatives considered + +- **仅在启动横幅中保留标识** — 未采用,因为对话较长时横幅会离开视野。 +- **仅在 `/status` 中显示会话 id** — 未采用,因为按需诊断无法让用户在发送输入前确认会话标识。 +- **将会话 id 放入页脚右侧区域** — 未采用,因为窄终端会优先裁剪该区域;会话标识比上下文和展开的工具卡片状态更重要。 + +## Consequences + +编辑器处于活动状态时,当前会话始终可识别。在窄终端中,更长的左侧区域会减少上下文和展开的工具卡片标签的显示空间;现有裁剪策略会保留会话标识、模型,以及空间允许的其他运行信息。 + +包级覆盖固定页脚顺序和转义路径,可运行 TUI 的终端快照固定组装后的布局。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml new file mode 100644 index 0000000000..177ddc27e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.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 +2026-07-23-tui-status-prompt-tools.md: 42524d021d0f2786371762b447ad5d195dc828bd +2026-07-23-tui-status-prompt-tools.zh.md: 5a33e19e9780749a721395a0b07f43790103013c diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md new file mode 100644 index 0000000000..42524d021d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md @@ -0,0 +1,29 @@ +# Agent Note: TUI status inspects model request inputs + +Status: implemented + +English | [中文](2026-07-23-tui-status-prompt-tools.zh.md) + +## Problem + +Session counters describe activity but do not reveal the instructions and capabilities that the next model request receives. Diagnosing scoped prompt contributions and tool restrictions otherwise requires leaving the TUI or inferring configuration from files. + +## Decision + +`/status` assembles the current agent's system prompt through `ctx.systemPrompt` and renders it with the same renderer used by the agent loop. After the bordered diagnostics card, separate unbordered `System prompt` and `Registered tools` sections show the rendered prompt and the assembly's ordered tool names, which are the schemas exposed to the model for that agent and presentation mode. + +Assembly uses the command's cancellation signal and current agent scope, so scoped sections, variables, tool restrictions, and assembly listeners match a request made at that point. Prompt and tool values are escaped through the TUI's terminal-control sanitizer before rendering. Empty prompt text and an empty tool list render as `(empty)` and `(none)`. + +## Alternatives considered + +**Read prompt sections and the tool registry independently.** Rejected: that bypasses prompt assembly waterfalls, tool ordering, presentation modes, and per-agent restrictions, so the diagnostics could disagree with the next request. + +**Show complete tool schemas.** Rejected: names answer which capabilities are registered without making the status card dominated by parameter JSON; schema details remain available in the generated tool catalog and source definitions. + +## Consequences + +The command can run prompt providers and assembly listeners, just like request preparation, and reports their failures through the existing command-error notice. The snapshot is point-in-time: a later registration, restriction, mode change, or dynamic provider can alter the next request. + +## Testing + +Unit coverage pins scoped assembly output, ordered tool names, empty labels, and terminal-control escaping. The keyless TUI smoke and terminal snapshot exercise `/status` through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md new file mode 100644 index 0000000000..5a33e19e97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 状态检查模型请求输入 + +Status: implemented + +[English](2026-07-23-tui-status-prompt-tools.md) | 中文 + +## 问题 + +会话计数器可以描述活动情况,却无法显示下一次模型请求将收到的指令和能力。若要诊断按作用域贡献的提示词与工具限制,用户只能离开 TUI,或根据配置文件进行推断。 + +## 决策 + +`/status` 通过 `ctx.systemPrompt` 为当前 agent(智能体)组装系统提示词,并使用与 agent loop(智能体循环)相同的渲染器完成渲染。在带边框的诊断卡片之后,独立且无边框的 `System prompt` 和 `Registered tools` 区域分别显示渲染后的提示词与 assembly 中按顺序排列的工具名称;这些名称对应当前 agent 与呈现模式向模型公开的 schema。 + +组装使用命令的取消信号和当前 agent 作用域,因此按作用域注册的 section、变量、工具限制及 assembly listener 与此时发起的请求保持一致。提示词和工具值在呈现前经过 TUI 的终端控制字符净化。空提示词与空工具列表分别显示为 `(empty)` 和 `(none)`。 + +## 曾考虑的替代方案 + +**分别读取提示词 section 和工具注册表。** 已否决:该做法会绕过提示词组装 waterfall(瀑布式事件)、工具排序、呈现模式和按 agent 限制,因此诊断结果可能与下一次请求不一致。 + +**显示完整工具 schema。** 已否决:工具名称足以回答注册了哪些能力,同时避免参数 JSON 占据大部分状态卡片;schema 详情仍可在生成的工具目录和源代码定义中查看。 + +## 后果 + +该命令可能像请求准备一样运行提示词提供方与 assembly listener,并通过现有命令错误提示报告失败。结果是一个时点快照:后续注册、限制、模式变更或动态提供方都可能改变下一次请求。 + +## 测试 + +单元测试固定按作用域组装的输出、工具名称顺序、空值标签和终端控制字符转义。无密钥 TUI 冒烟测试与终端快照通过完整组装的应用执行 `/status`。 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 new file mode 100644 index 0000000000..4532ab2148 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.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 +2026-07-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 +2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa 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 new file mode 100644 index 0000000000..4008f23a3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -0,0 +1,39 @@ +# Agent Note: TUI prompt themes compose mutable plugin values + +Status: implemented + +English | [中文](2026-07-24-configurable-tui-prompt-theme.zh.md) + +## Problem + +The terminal prompt row and editor prefix were assembled inside the TUI from a fixed set of workspace, model, usage, cache, context, and timing fields. Deployments could change colors globally but could not choose field order, replace the input prefix, add plugin state, or build a Powerline prompt. + +## Decision + +The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPrompt`, and the static running-state `inputPlaceholder`. The three prompt strings interpolate `${name}` references; unknown or unavailable values disappear with adjacent horizontal separator whitespace. The left and right templates share one row, retain the right side on overlap, and use ANSI-aware visible widths. The input template controls the first-line editor prefix and continuation indentation. + +`ctx.tuiPrompt` is a context-global registry supplied by `@deepseek-ai/dsh-tui/prompt`. `register(name, initialValue)` returns a handle with `set(value)` and `dispose()`. Values are stored strings rather than callbacks: updates are explicit, unchanged strings are ignored, and a registration, mutation, or disposal schedules one coalesced notification. The renderer reads current values with `get(name)` and subscribes with `subscribe(listener)` to learn when to redraw. That subscription is a direct in-service callback, not a Cordis event, so a value changing on its own schedule still repaints without a bus entry other consumers would never use. Both `subscribe` and each registration are owned by the caller's Cordis effect, so they are removed when the subscriber's or contributor's fiber disposes. Each `subscribe` call is a distinct subscription keyed by record identity, so two fibers may pass the same callback and disposing one leaves the other live. The coalesced notification contains every observer — a synchronous throw, a rejected returned promise, and even an error hostile to string rendering (logs go through the non-throwing `errorChain`) — so one broken observer cannot starve the rest, and it re-checks each subscription's liveness during delivery so a listener that synchronously unsubscribes another in the same burst silences it immediately. Registration follows Cordis effect ownership, rejects duplicate names, and removes the value on plugin disposal. + +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. + +## Alternatives considered + +**Evaluate synchronous provider callbacks on every render.** Rejected: render-time plugin code adds an avoidable failure boundary; stored strings keep the render pass free of plugin evaluation. + +**Publish the change notification as a Cordis event.** Rejected: the notification has exactly one consumer (the TUI renderer for the current session), so a global typed event adds a bus entry, scoped-dispatch surface, and cross-plugin fan-out no one else observes. A direct `subscribe` callback contained inside the service carries the same coalesced redraw with less surface. + +**Expose semantic style roles instead of ANSI.** Rejected: semantic roles cannot express arbitrary Powerline background transitions without expanding the shared style protocol for each presentation technique. + +**Put prompt fields at the top level of TUI config.** Rejected: templates and color selection jointly define terminal presentation and belong under one `theme` object. + +## Consequences + +Prompt contributors depend on the TUI-specific registry and are loaded after the service but before the TUI consumer. The namespace is global to the Cordis context, matching the TUI's current single-session transcript ownership. Arbitrary ANSI is intentionally trusted: unsupported cursor-affecting sequences can disrupt layout, and alignment is reliable only for sequences understood by pi-tui's visible-width utilities. + +Changing `inputPrompt` through a registered value preserves editor text, cursor, history, completion, and focus because pi-tui supports replacing equal-width first and continuation prefixes in place. The static `inputPlaceholder` is sanitized and appears only while the agent runs and the editor is empty. + +## Testing + +Registry tests pin validation, duplicate rejection, updates, unavailable values, coalesced-notification containment, unsubscribe, disposal, interpolation, trailing-literal retention, whitespace cleanup, and ANSI preservation. TUI tests pin nested theme defaults, custom templates, out-of-band value redraw, mutable redraw, Powerline-capable fragments, dynamic input-prefix width, and the static running placeholder. The assembled TUI demo test pins service load order and config forwarding. 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 new file mode 100644 index 0000000000..daf15b54d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -0,0 +1,39 @@ +# Agent Note: TUI 提示符主题组合可变的插件值 + +Status: implemented + +[English](2026-07-24-configurable-tui-prompt-theme.md) | 中文 + +## 问题 + +终端提示符行与编辑器前缀原先在 TUI 内部由一组固定字段拼装而成,涵盖工作区、模型、用量、缓存、上下文与计时。部署方可以全局更改颜色,却无法调整字段顺序、替换输入前缀、加入插件状态,也无法构建 Powerline 风格的提示符。 + +## 决策 + +TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPrompt` 以及运行状态下的静态 `inputPlaceholder` 归为一组。三个提示符字符串通过插值引用 `${name}`;未知或不可用的值连同相邻的横向分隔空白一起消失。左右模板共用一行,重叠时保留右侧,宽度计算使用可识别 ANSI 的可见宽度。输入模板控制编辑器首行前缀与续行缩进。 + +`ctx.tuiPrompt` 是由 `@deepseek-ai/dsh-tui/prompt` 提供的上下文全局注册表。`register(name, initialValue)` 返回带 `set(value)` 与 `dispose()` 的句柄。存储的值是字符串而非回调:更新必须显式发起,未变化的字符串会被忽略,而一次注册、变更或 dispose 会安排一次合并后的通知。渲染器用 `get(name)` 读取当前值,并用 `subscribe(listener)` 订阅何时重绘。该订阅是服务内部的直接回调,而非 Cordis 事件,因此一个自行变化的值仍能重绘,而不需要一个其他消费方永远不会观察的总线条目。`subscribe` 与每个注册都由调用方的 Cordis effect 拥有,因此在订阅方或贡献方的 fiber dispose 时一并移除。每次 `subscribe` 都是一个按记录身份区分的独立订阅,因此两个 fiber 可以传入同一个回调,而 dispose 其中一个不会影响另一个。合并通知会容错每个观察者——同步抛出、返回被拒 promise,甚至一个对字符串渲染也会抛异常的错误(日志走不抛异常的 `errorChain`)——因此一个损坏的观察者不会饿死其余观察者;并且在派发过程中会重新校验每个订阅的存活性,因此同一批次中同步取消了另一个订阅的监听器会立即使其静默。注册遵循 Cordis 的 effect 所有权模型,拒绝重复名称,并在插件 dispose(资源释放)时移除对应的值。 + +注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 + +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 + +## 曾考虑的替代方案 + +**每次渲染时求值同步的提供方回调。** 不予采纳:在渲染期执行插件代码会引入一个本可避免的故障边界;存储字符串能让渲染过程不涉及插件求值。 + +**把变更通知发布为 Cordis 事件。** 已否决:该通知只有一个消费方(当前会话的 TUI 渲染器),因此全局类型事件会增加一个总线条目、scope 分发面以及无人观察的跨插件扇出。服务内部包裹的直接 `subscribe` 回调以更小的面积承载同样的合并重绘。 + +**暴露语义化的样式角色而非 ANSI。** 不予采纳:语义角色无法表达任意的 Powerline 背景色过渡,除非为每种呈现技巧扩展共享的样式协议。 + +**把提示符字段放在 TUI 配置顶层。** 不予采纳:模板与颜色选择共同定义终端呈现,应归属于同一个 `theme` 对象之下。 + +## 后果 + +提示符值的贡献插件依赖 TUI 专属的注册表,加载顺序位于该服务之后、TUI 消费方之前。命名空间对整个 Cordis 上下文全局生效,与 TUI 当前的单会话 transcript(文本记录)所有权一致。允许任意 ANSI 是有意的信任决策:不受支持的、影响光标的序列可能破坏布局,只有 pi-tui 可见宽度工具能理解的序列才能保证对齐可靠。 + +通过注册值更改 `inputPrompt` 时,编辑器文本、光标、历史、自动补全与焦点均得以保留,因为 pi-tui 支持原地替换等宽的首行与续行前缀。静态的 `inputPlaceholder` 会被清洗,且仅在 agent 运行且编辑器为空时显示。 + +## 测试 + +注册表测试固定校验、重名拒绝、更新、不可用值、合并通知的容错、取消订阅、dispose、插值、尾随字面保留、空白清理与 ANSI 保留等行为。TUI 测试固定嵌套主题默认值、自定义模板、带外值重绘、可变重绘、支持 Powerline 的片段、动态输入前缀宽度以及运行状态下的静态占位文本。组装后的 TUI 演示测试固定服务加载顺序与配置转发。 diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml new file mode 100644 index 0000000000..88fbc26ceb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.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 +2026-07-24-readable-xml-tool-output.md: 4f7327a7c6f5e2f04e36576da0fb739c34955e8a +2026-07-24-readable-xml-tool-output.zh.md: 3c56d256b489863210b44449111f03a5752a889a diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md new file mode 100644 index 0000000000..4f7327a7c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md @@ -0,0 +1,27 @@ +# Agent Note: Readable XML tool output + +Status: implemented + +English | [中文](2026-07-24-readable-xml-tool-output.zh.md) + +## Problem + +Model-facing context and tool result text can expose transport-oriented XML wrappers instead of the information people need. Context producers do not declare presentation intent, and replayed tool calls whose definition is unavailable still need a conservative fallback that does not reinterpret ordinary prose or partial markup. + +## Decision + +The read tool declares a generic completed-result presentation that removes its ``, ``, and `` wrapper while preserving the numbered content and footer. This tool-owned projection applies consistently to every UI that consumes tool presentation intent. + +The TUI parses a context message or unavailable-tool result as XML only when the complete text is one supported XML document. It renders element names and attributes as an indented tree, preserves the context source label, applies the collapsed line budget independently to each tool result's top-level child lines and child count, and keeps raw text for malformed XML, mixed text, declarations, processing instructions, doctypes, and comments. A known tool's raw XML remains literal unless that tool declares its own result presenter. This XML fallback is TUI-only. + +## Alternatives considered + +**Strip XML-like tags with regular expressions.** Rejected because nested elements, attributes, entities, and malformed input require a real parser; partial conversion would make ambiguous output harder to inspect. + +**Parse every generic result.** Rejected because known tools own their presentation contract, and silently reinterpreting their literal XML would override that decision. + +**Show only raw XML.** Rejected because wrappers optimized for model consumption add terminal noise, particularly for filesystem reads and deeply nested structured results. + +## Consequences + +Filesystem reads are shorter in TUI cards without changing canonical model-facing content. Complete XML context messages, including workspace instruction reminders, become readable trees; unknown complete XML results become navigable trees and retain per-child context when collapsed. The TUI adds a strict SAX parser dependency and deliberately declines XML features (undefined entities, DOCTYPE, comments, processing instructions) that could hide or transform input beyond the conservative tree view. Predefined entities and character references do expand, so parsed text and attribute values are re-escaped for terminal output after parsing: a character reference can produce a control character that escaping the raw source never saw. Other UIs show raw generic content. diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md new file mode 100644 index 0000000000..3c56d256b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 可读的 XML 工具输出 + +Status: implemented + +[English](2026-07-24-readable-xml-tool-output.md) | 中文 + +## 问题 + +面向模型的上下文和工具结果文本可能呈现面向传输的 XML 包装,而不是人们真正需要的信息。上下文生产方不声明呈现意图,而对于回放时拿不到工具定义的调用,仍需要一个保守的回退方案,并且该方案不得重新解释普通文字或不完整的标记。 + +## 决策 + +read 工具声明一个通用的完成结果呈现:去除自身的 ``、`` 和 `` 包装,同时保留带行号的内容和尾部信息。这一由工具自身持有的投影一致地作用于所有消费工具呈现意图的 UI。 + +只有当完整文本恰为一个受支持的 XML 文档时,TUI 才把上下文消息或工具定义不可用的工具结果按 XML 解析。TUI 将元素名和属性渲染为缩进树,保留上下文的来源标签;对于每个工具结果,分别按折叠行数预算限制各顶层子元素的行数和顶层子元素数量;对于格式错误的 XML、混合文本、XML 声明、处理指令、doctype 和注释,则保留原始文本。除非已知工具声明了自己的结果呈现器,否则其原始 XML 仍按字面显示。这一 XML 回退机制仅限 TUI。 + +## 曾考虑的替代方案 + +**用正则表达式剥除类 XML 标签。** 已否决:嵌套元素、属性、实体和格式错误的输入都需要真正的解析器;部分转换会让本就有歧义的输出更难检查。 + +**解析所有通用结果。** 已否决:已知工具拥有自己的呈现契约,静默重新解释它们的字面 XML 会推翻这一决定。 + +**只显示原始 XML。** 已否决:为模型消费而优化的包装会给终端增加噪音,对文件系统读取和嵌套很深的结构化结果尤其如此。 + +## 后果 + +文件系统读取在 TUI 卡片中变得更短,而规范的面向模型内容保持不变。完整的 XML 上下文消息(包括工作区指令提醒)变成可读的树;未知的完整 XML 结果变成可导航的树,折叠时也保留每个子元素的上下文。TUI 新增一个严格 SAX 解析器依赖,并有意不支持那些可能在保守树视图之外隐藏或变换输入的 XML 特性(未定义实体、DOCTYPE、注释、处理指令)。预定义实体和字符引用会被展开,因此解析出的文本和属性值在解析后会为终端输出重新转义:字符引用可能产生对原始源文本转义时从未见过的控制字符。其他 UI 展示原始的通用内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml new file mode 100644 index 0000000000..8cb52df982 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.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-24-tui-banner-model-deduplication.md +2026-07-24-tui-banner-model-deduplication.md: afd370a8762d8e6c17c61d50c95d68998a063df5 +2026-07-24-tui-banner-model-deduplication.zh.md: 86a3cdb0e714642253162f1fe062e19bdc40bbe4 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md new file mode 100644 index 0000000000..afd370a876 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md @@ -0,0 +1,33 @@ +# Agent Note: The startup banner omits the model + +Status: implemented + +English | [中文](2026-07-24-tui-banner-model-deduplication.zh.md) + +## Problem + +The startup banner repeated the selected model directly above the prompt context, which already keeps the model visible while the TUI is idle. The duplicate added no information and made the banner detail line harder to scan. + +## Decision + +- The borderless startup banner shows the product title, optional `welcome` or session-title subtitle, and session id. +- The banner omits the model name. The prompt context remains the persistent model display and updates after `/model` selection. +- The sweep animation and configured-welcome behavior are unchanged. + +This supersedes only the model-in-banner portion of the [borderless banner decision](../../archived/feature/2026-07-21-tui-borderless-banner.md). + +## Alternatives considered + +**Remove the entire detail line.** Rejected: the session id remains useful for identifying and resuming the active session, and it is not duplicated in the prompt context. + +**Remove the model from the prompt context instead.** Rejected: the prompt context stays visible after the startup banner scrolls away and reflects later model selections. + +## Consequences + +- Startup uses the banner detail row only for the session id. +- The model appears once in the initial idle view, in the prompt context. +- Banner snapshots and runnable TUI replay snapshots contain a shorter detail row. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` asserts that completed banners retain the session id without the former `` text. Package-local and runnable-example TUI snapshots pin the resulting rows. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md new file mode 100644 index 0000000000..86a3cdb0e7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md @@ -0,0 +1,33 @@ +# Agent Note:启动横幅不再显示模型 + +Status: implemented + +[English](2026-07-24-tui-banner-model-deduplication.md) | 中文 + +## 问题 + +启动横幅在提示区上下文(prompt context)的正上方重复显示所选模型,而提示区上下文本身已在 TUI 空闲时持续展示模型。这一重复不提供任何信息,还让横幅详情行更难扫读。 + +## 决策 + +- 无边框启动横幅显示产品标题、可选的 `welcome` 或会话标题副标题,以及会话 id。 +- 横幅不再显示模型名。提示区上下文仍是常驻的模型展示位,并在 `/model` 选择后随之更新。 +- 扫入动画和配置了欢迎语时的行为保持不变。 + +本 note 仅取代[无边框横幅决策](../../archived/feature/2026-07-21-tui-borderless-banner.md)中模型进横幅的那部分。 + +## 考虑过的替代方案 + +**移除整条详情行。** 否决:会话 id 对识别和恢复当前会话仍然有用,而且它在提示区上下文中没有重复。 + +**改为把模型从提示区上下文移除。** 否决:提示区上下文在启动横幅滚出视野后仍保持可见,并会反映之后的模型选择。 + +## 后果 + +- 启动时横幅详情行只承载会话 id。 +- 在初始空闲视图中模型只出现一次,位于提示区上下文。 +- 横幅快照和可运行的 TUI 回放快照包含更短的详情行。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 断言完成后的横幅保留会话 id,且不含先前的 `` 文本。包内快照与可运行示例的 TUI 快照固定了最终的各行内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml new file mode 100644 index 0000000000..481dc5cad7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.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 +2026-07-24-tui-message-header-timing.md: 94a4d04c75f9b0ad76e2460738a07ba82ac3bb9f +2026-07-24-tui-message-header-timing.zh.md: 4713555290bbc47bb3af56cd3b4d0c493c81e6f1 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md new file mode 100644 index 0000000000..94a4d04c75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md @@ -0,0 +1,25 @@ +# Agent Note: TUI message header timing + +Status: implemented + +English | [中文](2026-07-24-tui-message-header-timing.zh.md) + +## Problem + +Turn timing beside the editor disappears from the transcript when the user scrolls and cannot appear until the editor status renders. A whole-turn aggregate also obscures the latency of later model requests after tool calls. + +## Decision + +Every model step creates an assistant header at `step/start`, before the first streamed chunk. The header displays `Model wait` immediately and refreshes at 100 ms resolution, then adds exclusive `Thinking`, `Response`, and `Tools` buckets as session events move the step between phases. + +`step/end` freezes the header and adds the local completion timestamp. Transcript replay derives the same timing from durable event timestamps. Empty and tool-only steps retain a header, while failed live output and its header retract together when retry handling rebuilds the active session surface. + +The prompt context retains only queued-steering state. Timing belongs to the model step that produced it rather than to the editor or the whole turn. + +## Alternatives considered + +Keeping timing beside the editor preserves a stable layout but hides per-step latency in scrollback and resume. Adding a second status line duplicates the same metric in two places. Labeling the first bucket `TTFT` is compact but requires protocol terminology; `Model wait` states the user-visible meaning without claiming that the first chunk is always text. + +## Consequences + +Users receive visible feedback before model output and can compare each request after tools or retries. Updating at 100 ms resolution causes more terminal renders while a model step is active. Internal timing state keeps the established `ttft` name because it identifies the measured bucket precisely; only rendered text uses `Model wait`. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md new file mode 100644 index 0000000000..4713555290 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md @@ -0,0 +1,25 @@ +# Agent Note:TUI 消息头部计时 + +Status: implemented + +[English](2026-07-24-tui-message-header-timing.md) | 中文 + +## 问题 + +编辑器旁的轮次计时会在用户滚动时从 transcript(文本记录)中消失,且要等到编辑器状态渲染后才能出现。整轮聚合值还会掩盖工具调用之后各后续模型请求的延迟。 + +## 决策 + +每个模型步骤都在 `step/start` 时(即第一个流式分片到达之前)创建一个 assistant 头部。头部立即显示 `Model wait` 并以 100 ms 分辨率刷新;随着会话事件使该步骤在不同阶段之间切换,头部再加入互斥的 `Thinking`、`Response` 和 `Tools` 时间桶。 + +`step/end` 冻结头部并附上本地完成时间戳。transcript 回放从持久事件时间戳派生出相同的计时。空步骤和纯工具步骤同样保留头部;当重试处理重建活跃会话表层时,失败的实时输出与其头部一并撤除。 + +提示区上下文(prompt context)只保留排队中的 steering(中途引导)状态。计时归属于产生它的模型步骤,而不是编辑器或整个轮次。 + +## 考虑过的替代方案 + +把计时留在编辑器旁能保持布局稳定,但在 scrollback 和会话恢复中看不到各步骤的延迟。增加第二条状态行会让同一指标出现在两处。把第一个时间桶标为 `TTFT` 更紧凑,但依赖协议术语;`Model wait` 直接陈述用户可见的含义,而不宣称第一个分片总是文本。 + +## 后果 + +用户在模型输出之前就能得到可见反馈,并能比较工具或重试之后的每次请求。以 100 ms 分辨率刷新会在模型步骤活跃期间带来更多终端渲染。内部计时状态沿用既有的 `ttft` 名称,因为它精确标识所计量的时间桶;只有渲染文本使用 `Model wait`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml new file mode 100644 index 0000000000..dff4800cf7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.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 +2026-07-24-tui-prompt-status-indicator.md: 8d469c3b0627325f373ca8f8d4d23d67bb09e342 +2026-07-24-tui-prompt-status-indicator.zh.md: 0dee8d1e4e7ce5a6f0299f9b7cd1595f63bd6e08 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md new file mode 100644 index 0000000000..8d469c3b06 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md @@ -0,0 +1,33 @@ +# Agent Note: TUI prompt status indicator + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-status-indicator.zh.md) + +## Problem + +While a turn runs, the input prompt shows only its static `dsh>` prefix. The assistant header carries the elapsed timing, but the editor row — where the user's attention rests — gives no live signal of what the agent is doing right now: waiting for the first token, thinking, responding, or running tools. + +## Decision + +While the agent is running, a phase-specific glyph replaces the `>` caret of the built-in `${indicator}` prompt value. The `inputPrompt` theme template defaults to `${symbol} ${indicator}`, where the built-in `${symbol}` value holds the `dsh` label and `${indicator}` holds the caret slot with its trailing gap before the cursor; the template literal space separates them, rendering `dsh ` in every state. The phase is the open step's active timing bucket, derived from the same session events and rules that drive the [message header timing](2026-07-24-tui-message-header-timing.md) — no new phase model. One glyph per bucket: `◍` model wait (pre-first-token), `✻` thinking, `●` responding, `⚙` tools. A running turn with no open step falls back to the model-wait glyph; an idle agent restores the plain `>`. + +The glyph occupies the caret's exact column with the same display width every frame, so the cursor never shifts as the phase changes or the glyph animates. Activity is conveyed by a brightness pulse, not by appearing and disappearing: a four-frame triangle wave (dim → normal → bold → normal) wraps the accent-colored glyph in the true SGR intensity codes (2 and 1) — never the palette's semantic `dim` role, which on a light scheme is a color the glyph's own accent would override — so the pulse survives every terminal scheme. The render-clock cadence is 250 ms per frame, a fixed presentation rhythm alongside the sibling 100 ms status refresh, not a deployment choice. The running-status timer refreshes every 100 ms tick unconditionally rather than only when a streaming component exists, so the pulse animates even during the pre-first-token wait. + +The caret and its animation are their own `${indicator}` value, separate from the `${symbol}` label, so the `inputPrompt` template composes the two: `${symbol} ${indicator}` reads as `dsh `. Configurability lives at that template — a deployment reorders or drops either value, and omitting `${indicator}` opts out of the running indicator. The glyph set, the pulse, and the `dsh` label are fixed in code — not per-deployment fields — matching the fixed timing-bucket labels they mirror. + +The built-in `${symbol}`/`${indicator}` updates ride the renders the TUI already drives on every state change that can move a value (`agent/status`, session events, the 100 ms running-status timer, async model-context resolution). A prompt value that changes on its own schedule — a plugin-owned `${custom}` fragment — instead redraws through the registry's coalesced change notification, which the renderer subscribes to directly rather than through a Cordis event ([registry](2026-07-24-configurable-tui-prompt-theme.md)). + +## Alternatives considered + +**Prepend the glyph before `dsh>` as its own `${status}` token.** Rejected: a leading token shifts the whole prompt — and the cursor — right by two columns whenever it appears, and collapses back when it clears. Replacing the caret keeps the cursor column fixed. + +**A blinking glyph that appears and disappears.** Rejected: on/off blanking still moves nothing horizontally once the glyph owns the caret column, but the empty frames read as flicker. A brightness pulse animates continuously while the character stays put. + +**A per-phase spinner animation** (rotating frames). Rejected: the four phases are already distinguished by their glyph shapes; swapping the character per frame would conflate "which phase" with "still working". The pulse animates intensity while the shape stays a stable phase signal, reusing the existing 100 ms status timer. + +**A new phase state machine in the TUI.** Rejected: the header-timing machinery already replays the open step's active bucket from session events. Deriving the glyph from that bucket keeps one source of truth for "what phase is this step in". + +## Consequences + +The user gets a live, glanceable phase signal in the caret they are already watching, with no horizontal movement of the cursor or the prompt. The pulse costs terminal renders on every 100 ms tick for the whole running turn, not only while a streaming component is mounted. The glyph mapping and the pulse are fixed in code, not configurable, matching the fixed timing-bucket labels they mirror. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md new file mode 100644 index 0000000000..0dee8d1e4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md @@ -0,0 +1,33 @@ +# Agent Note:TUI 提示区状态指示器 + +Status: implemented + +[English](2026-07-24-tui-prompt-status-indicator.md) | 中文 + +## 问题 + +轮次运行期间,输入提示区只显示其静态的 `dsh>` 前缀。assistant 头部承载已用计时,但编辑器所在的这一行——也就是用户注意力所在之处——对 agent 此刻正在做什么没有任何实时信号:是在等待第一个 token、思考、响应,还是在运行工具。 + +## 决策 + +agent 运行期间,一个按阶段区分的字形会替换内置 `${indicator}` 提示区值中的 `>` 光标符。`inputPrompt` 主题模板默认为 `${symbol} ${indicator}`,其中内置 `${symbol}` 值承载 `dsh` 标签,`${indicator}` 承载光标符槽位及其在光标前的尾随间隙;模板中的字面空格将两者隔开,在每种状态下渲染为 `dsh <字形> `。阶段取自当前打开步骤的活跃计时桶,其派生所依据的会话事件与规则和[消息头部计时](2026-07-24-tui-message-header-timing.md)相同——没有引入新的阶段模型。每个桶对应一个字形:`◍` 等待模型(第一个 token 之前)、`✻` 思考、`●` 响应、`⚙` 工具。运行中但没有打开步骤的轮次回退到等待模型的字形;agent 空闲时则恢复为纯 `>`。 + +字形占据光标符所在的同一列,且每一帧的显示宽度都相同,因此无论阶段切换还是字形动画,光标都不会移动。活动状态由亮度脉动传达,而不是靠出现和消失:一个四帧三角波(暗 → 正常 → 亮 → 正常)用真正的 SGR 强度码(2 与 1)包裹带 accent 色的字形——绝不使用调色板语义上的 `dim` 角色,因为在浅色 scheme 下它是一种颜色,会被字形自身的 accent 色覆盖——因此脉动在任何终端 scheme 下都能保留。渲染时钟节拍为每帧 250 ms,是与配套的 100 ms 状态刷新并列的固定呈现节奏,而非部署选项。运行状态计时器每 100 ms 无条件刷新一次,而不再只在存在流式组件时刷新,因此即使在第一个 token 之前的等待期间,脉动也能持续。 + +光标符及其动画自成一个 `${indicator}` 值,与 `${symbol}` 标签分离,因此 `inputPrompt` 模板将二者组合:`${symbol} ${indicator}` 读作 `dsh <光标符>`。可配置性位于该模板——部署可重排或丢弃任一值,省略 `${indicator}` 即退出运行指示器。字形集、脉动以及 `dsh` 标签都固定在代码中——不是逐部署字段——与它们映射的固定计时桶标签一致。 + +内置 `${symbol}`/`${indicator}` 的更新搭乘 TUI 本就在每次可能改变某个值的状态变化(`agent/status`、会话事件、100 ms 运行状态计时器、异步模型上下文解析)时驱动的渲染。而一个自行变化的值——插件拥有的 `${custom}` 片段——则通过注册表的合并变更通知重绘,而渲染器直接订阅它,而非通过 Cordis 事件(参见[注册表](2026-07-24-configurable-tui-prompt-theme.md))。 + +## 考虑过的替代方案 + +**把字形作为自己的 `${status}` token 前置在 `dsh>` 之前。** 已否决:前置 token 每次出现都会把整个提示区——连同光标——向右移动两列,清除时又缩回。在尾随的 `${indicator}` 槽位替换光标符能让光标列保持固定。 + +**出现又消失的闪烁字形。** 已否决:一旦字形占据光标符所在列,开/关式的空白帧在水平方向上不再移动任何东西,但空帧读起来像闪烁。亮度脉动让字符保持不动的同时持续做动画。 + +**按阶段的 spinner 动画**(旋转帧)。已否决:四个阶段已经通过各自的字形形状区分;逐帧切换字符会把「哪个阶段」与「仍在工作」混为一谈。脉动只改变强度做动画,而形状始终是稳定的阶段信号,且复用了既有的 100 ms 状态计时器。 + +**在 TUI 中新建阶段状态机。** 已否决:头部计时机制已从会话事件回放出当前打开步骤的活跃桶。从该桶派生字形,能让「这个步骤处于哪个阶段」保持单一事实来源。 + +## 后果 + +用户在自己本就注视的光标符处获得可一眼掌握的实时阶段信号,且光标与提示区都没有水平移动。脉动的代价是整个运行轮次内每 100 ms 一次的终端渲染,而不再只在流式组件挂载期间。字形映射与脉动都固定在代码中、不可配置,与其所对应的固定计时桶标签一致。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml new file mode 100644 index 0000000000..dd09857974 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.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 +2026-07-24-tui-prompt-workspace-label.md: c45c60c6554766cca01076b229956a7bc7d98d48 +2026-07-24-tui-prompt-workspace-label.zh.md: 170dba83becf4b529679e7db9c7c84a6de7dec13 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md new file mode 100644 index 0000000000..c45c60c655 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md @@ -0,0 +1,34 @@ +# Agent Note: The prompt context combines directory and branch + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-workspace-label.zh.md) + +## Problem + +The idle prompt context rendered the working directory and `git:` as separate segments. In task worktrees, the directory can already identify the checkout, while the prefixed branch segment consumed additional horizontal space and was discarded independently on narrower terminals. + +## Decision + +- The prompt context renders the working directory and available Git branch as one workspace label: ` ()`. +- The directory remains bold and accented; the parenthesized branch remains muted. +- The combined workspace label has the highest retention priority and is clipped as one segment when it exceeds the terminal width. +- Outside a Git worktree or on detached HEAD, the label remains the directory alone. + +## Alternatives considered + +**Keep `git:` as a separate segment.** Rejected: the prefix and separator use more columns without adding meaning in this context. + +**Show only the branch.** Rejected: the session working directory determines where tools operate and remains the primary prompt context. + +**Derive a special worktree root label.** Rejected: the existing formatted directory and Git branch already provide the two relevant facts without adding repository-layout assumptions. + +## Consequences + +- A typical checkout renders as `~/git/tui-staging (tui-staging)`. +- Narrow terminals retain or clip directory and branch together instead of dropping the branch independently. +- Embedding-provided `TuiRuntime.formatCwd` labels compose with the branch in the same form. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins home, absolute, formatted, and narrow workspace labels. Package-local and runnable-example TUI snapshots verify the assembled prompt context. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md new file mode 100644 index 0000000000..170dba83be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md @@ -0,0 +1,34 @@ +# Agent Note:提示区上下文合并显示目录与分支 + +Status: implemented + +[English](2026-07-24-tui-prompt-workspace-label.md) | 中文 + +## 问题 + +空闲提示区上下文(prompt context)把工作目录和 `git:` 作为两个独立片段渲染。在任务 worktree 中,目录本身往往已能标识当前检出,而带前缀的分支片段额外占用横向空间,且在较窄的终端上会被单独丢弃。 + +## 决策 + +- 提示区上下文把工作目录和可用的 Git 分支渲染为一个工作区标签(workspace label):` ()`。 +- 目录仍为加粗强调色;括号内的分支仍为弱化色。 +- 合并后的工作区标签具有最高保留优先级,超出终端宽度时作为一个整体片段裁剪。 +- 不在 Git worktree 中或处于 detached HEAD 时,标签仍只显示目录。 + +## 考虑过的替代方案 + +**保留 `git:` 作为独立片段。** 否决:前缀和分隔符占用更多列宽,在此上下文中却不增加信息。 + +**只显示分支。** 否决:会话工作目录决定工具在哪里运行,仍是提示区上下文的首要信息。 + +**派生一个特殊的 worktree 根目录标签。** 否决:现有的格式化目录和 Git 分支已经提供了这两项相关信息,无需引入对仓库布局的假设。 + +## 后果 + +- 典型的检出渲染为 `~/git/tui-staging (tui-staging)`。 +- 窄终端把目录和分支作为整体保留或裁剪,而不是单独丢弃分支。 +- 嵌入方通过 `TuiRuntime.formatCwd` 提供的标签以同样的形式与分支组合。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 固定了主目录、绝对路径、格式化及窄终端下的工作区标签。包内快照与可运行示例的 TUI 快照验证了组装后的提示区上下文。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml new file mode 100644 index 0000000000..bfb40b9cc8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.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 +2026-07-24-tui-shell-prompt-editor.md: bba03e788b92692f534fd97e66035757e9c74356 +2026-07-24-tui-shell-prompt-editor.zh.md: 1897d11292ec3b189245169956ac327f0b81b0e2 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md new file mode 100644 index 0000000000..bba03e788b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell-prompt editor + +Status: implemented + +English | [中文](2026-07-24-tui-shell-prompt-editor.zh.md) + +## Problem + +The upstream pi-tui editor always renders horizontal frame rows. That presentation separates input from the transcript but occupies two terminal rows and does not resemble the command-oriented input used by shells. + +## Decision + +The TUI presents a two-line prompt. A DSH-owned context line shows the working directory, running-turn timing, optional Git branch, current model, token totals, cache hit rate, and context pressure as independently prioritized segments. Narrow terminals omit lower-priority segments while retaining the directory, followed by running timing when it is present. The second line uses a fixed-width `dsh> ` prefix and equal-width continuation indent; its running steer/cancel guidance is placeholder text that disappears when input begins. + +The pinned `@earendil-works/pi-tui` package carries a pnpm patch that adds `frame: "none"` and fixed-width prompt prefixes to `EditorOptions`. The default remains the upstream horizontal frame, so only the DSH editor opts into the behavior. Prefixes must have equal visible widths; construction fails when they differ. Input, explicit newlines, autocomplete, cursor placement, and scroll indicators share the reduced first-row width; automatically wrapped rows render no prefix, so their text starts at the editor's left padding, occupies the prefix columns, and wraps at the full content width. + +The patch stays limited to the published editor JavaScript and declarations. Keeping the exact dependency pin makes installation either apply the known patch or fail rather than silently dropping the presentation. + +## Alternatives considered + +**Filter the rendered editor output in a wrapper.** This would depend on recognizing ANSI-styled border and scroll-indicator rows and distinguishing autocomplete output from input output, all of which are undocumented render details. + +**Vendor the complete pi-tui package.** The project updates frequently, while this change needs only a localized editor rendering option. Owning the full source and synchronization process would add disproportionate maintenance. + +**Keep the horizontal frame.** This avoids dependency customization but retains the presentation the change is intended to replace. + +## Consequences + +The editor and context use two rows instead of the framed editor plus footer, with one blank row separating the prompt area from conversation cards. The persistent presentation omits session identity and tool-card mode; `/status` and commands retain those details. Input layout and autocomplete lose six columns to the prompt prefix, but wrapped text uses the otherwise blank prefix columns. Borderless scrolling uses standalone `↑ N more` and `↓ N more` rows. + +The internal segment representation establishes width priorities without exposing a public customization language. Future Starship-like configuration can build on it after the default modules and overflow behavior have production evidence. + +A pi-tui upgrade requires reviewing and reapplying or retiring the patch. TUI terminal snapshots pin the assembled presentation, including context modules, prompt color, alignment, cursor placement, and autocomplete width. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md new file mode 100644 index 0000000000..1897d11292 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell 提示符编辑器 + +Status: implemented + +[English](2026-07-24-tui-shell-prompt-editor.md) | 中文 + +## 问题 + +上游 pi-tui 编辑器始终渲染横向边框行。这种呈现方式虽然把输入区与 transcript(文本记录)分隔开,却占用两行终端高度,也不像 shell 中面向命令的输入形态。 + +## 决策 + +TUI 呈现两行提示符。DSH 自有的上下文行把工作目录、运行中轮次的计时、可选的 Git 分支、当前模型、token 总量、缓存命中率与上下文压力显示为各自独立分配优先级的段(segment)。窄终端会省略低优先级的段,但保留目录;运行中计时存在时,其保留优先级仅次于目录。第二行使用固定宽度的 `dsh> ` 前缀与等宽的续行缩进;agent 运行期间提示 steering(中途引导)与取消的引导文字是占位文本,开始输入后即消失。 + +固定版本的 `@earendil-works/pi-tui` 包(package)携带一个 pnpm 补丁,为 `EditorOptions` 增加 `frame: "none"` 与固定宽度的提示符前缀。默认值仍是上游的横向边框,因此只有 DSH 编辑器选择启用该行为。两个前缀的可见宽度必须相等;宽度不同时构造会失败。输入、显式换行、自动补全、光标定位和滚动指示共用缩减后的首行宽度;自动折行产生的行不渲染前缀,其文本从编辑器左侧留白处开始,占用前缀列,并按完整内容宽度折行。 + +补丁范围仅限已发布的编辑器 JavaScript 与类型声明。依赖保持精确的版本固定,使安装要么应用已知补丁,要么直接失败,而不会静默丢掉这种呈现方式。 + +## 曾考虑的替代方案 + +**在包装层过滤编辑器的渲染输出。** 这需要识别带 ANSI 样式的边框行与滚动指示行,并区分自动补全输出与输入输出,而这些都是未见于文档的渲染细节。 + +**vendor 完整的 pi-tui 包。** 该项目更新频繁,而本次改动只需要一个局部的编辑器渲染选项。接手全部源码及其同步流程会带来不成比例的维护成本。 + +**保留横向边框。** 这可以避免定制依赖,但保留的正是本次改动想要替换的呈现方式。 + +## 后果 + +编辑器与上下文共占两行,取代原先带边框的编辑器加页脚,提示符区域与对话卡片之间以一行空行分隔。常驻呈现不含会话标识与工具卡片模式;`/status` 与各命令仍保留这些细节。输入布局与自动补全因提示符前缀占位而损失六列宽度,但折行后的文本会占用原本留空的前缀列。无边框滚动使用独立的 `↑ N more` 与 `↓ N more` 行。 + +段的内部表示确立了宽度优先级,而未暴露公开的定制语言。待默认模块与溢出行为积累生产环境证据后,未来可在其上构建类似 Starship 的配置。 + +升级 pi-tui 时需要评审该补丁,并重新应用或将其退役。TUI 终端快照固定组装后的呈现效果,包括上下文模块、提示符颜色、对齐、光标定位和自动补全宽度。 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml new file mode 100644 index 0000000000..dab40896a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.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-assistant-timing-header-trailing.md +2026-07-27-assistant-timing-header-trailing.md: a315a0620c63f25660220e55cf5187d7117c14d1 +2026-07-27-assistant-timing-header-trailing.zh.md: 84b8612337a345912371e37952195e4602f7ca25 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md new file mode 100644 index 0000000000..a315a0620c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +English | [中文](2026-07-27-assistant-timing-header-trailing.zh.md) + +## Problem + +The TUI assistant message opened with a single header line joining the `Assistant` label and the step-timing string (`Assistant · Model wait 0.0s · Completed …`). Placing the timing before the body pushed the durations away from the answer they describe and, once completed, buried the reply's first line under a metadata line the reader scans past. + +## Decision + +**Split the label from the timing; render the timing as the message's trailing line.** + +`AssistantMessageComponent` (packages/ui/tui/src/index.ts) now emits the bold `Assistant` label as the first line and appends the dim timing string (already assembled by `StreamingAssistantComponent.rebuild()` as `header`, including the `· Completed …` suffix when settled) as the last child, after reasoning and text. The timing content, bucket-hiding, and completion-time behavior are unchanged — only its position moved from the top to the bottom of the message. + +## Alternatives considered + +**Move the whole header line (label included) to the end.** Rejected: the `Assistant` label orients the reader to who is speaking and belongs at the top like the `You` label; only the timing metadata benefits from trailing placement. + +**Keep the timing inline but below the label as a second top line.** Rejected: that still separates the durations from the completed answer and keeps two metadata lines between the prompt and the reply. + +## Consequences + +Each assistant message reads label → reasoning → answer → timing, so completed timing sits next to the reply it measures. The keyless TUI snapshot suite was refreshed to pin the new layout across every fixture; four `tui.spec.ts` assertions that matched the old inline `Assistant · Model wait …` string now assert the label and timing separately, since the two no longer render contiguously. diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md new file mode 100644 index 0000000000..84b8612337 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +[English](2026-07-27-assistant-timing-header-trailing.md) | 中文 + +## Problem + +TUI 的助手消息此前以一行开头,把 `Assistant` 标签和步骤计时串拼在一起(`Assistant · Model wait 0.0s · Completed …`)。计时放在正文之前,使耗时数据远离它所描述的回答;一旦完成,回复的首行还被读者会略过的元数据行压在下面。 + +## Decision + +**把标签与计时拆开;计时作为消息的末行渲染。** + +`AssistantMessageComponent`(packages/ui/tui/src/index.ts)现在把加粗的 `Assistant` 标签作为首行,并把暗色的计时串(仍由 `StreamingAssistantComponent.rebuild()` 组装为 `header`,settled 时含 `· Completed …` 后缀)作为最后一个子节点,追加在 reasoning 与正文之后。计时内容、隐藏零值桶以及完成时间的行为均不变——仅位置从消息顶部移到底部。 + +## Alternatives considered + +**把整行表头(含标签)都移到末尾。** 否决:`Assistant` 标签让读者知道是谁在说话,应与 `You` 标签一样置顶;只有计时这类元数据才受益于置底。 + +**计时仍内联,但作为标签下方的第二行置顶。** 否决:这仍把耗时数据与完成的回答分离,并在提示与回复之间保留两行元数据。 + +## Consequences + +每条助手消息按 标签 → reasoning → 回答 → 计时 阅读,完成计时紧挨它所度量的回复。无密钥的 TUI 快照套件已刷新,在每个 fixture 中固定新布局;`tui.spec.ts` 中四处原先匹配旧内联串 `Assistant · Model wait …` 的断言,现改为分别断言标签与计时,因为两者不再连续渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml new file mode 100644 index 0000000000..2012fedaaa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.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-tui-running-glyph-smooth-fade.md +2026-07-27-tui-running-glyph-smooth-fade.md: e4c8fee399c2269bfe53976d3358bc643b2daf6a +2026-07-27-tui-running-glyph-smooth-fade.zh.md: 25bda3d549b1a7548e997f8801858d1efa32e3eb diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md new file mode 100644 index 0000000000..e4c8fee399 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +English | [中文](2026-07-27-tui-running-glyph-smooth-fade.zh.md) + +## Problem + +While a turn runs, the TUI replaces the `>` prompt caret with a phase glyph (`◍`/`✻`/`●`/`⚙`). Earlier iterations animated its brightness in the accent blue (a discrete SGR wave, then a truecolor throb) — a colored, always-pulsing indicator. The desired effect keeps the continuous pulse to signal ongoing work, but as a quiet dim gray rather than a color, and with smooth fade-in and fade-out at its edges. + +## Decision + +The running glyph is a dim gray that fades in on turn start, throbs continuously while the turn runs, and fades out after it ends before the plain `>` caret returns. It is never the accent color. + +Brightness is a fade envelope times a running throb. The envelope gates appear/disappear, linear in the render clock over `STATUS_FADE_MS = 300`: `(now − startedAt)/FADE` clamped for fade-in, `1 − (now − endedAt)/FADE` for fade-out. `pulseLevel` is a cosine between `STATUS_PULSE_FLOOR` (0) and 1 over `STATUS_PULSE_PERIOD_MS = 1400`, so each breath swells from fully invisible to full and back. The truecolor opacity handed to `fadeGlyph` is `envelope × pulse`. + +`fadeGlyph` renders at that opacity. With truecolor, below `STATUS_FADE_MIN_OPACITY` (0.12) the glyph is hidden entirely — a blank column — so the pulse trough disappears rather than lingering as a near-background gray; above it the glyph interpolates a 24-bit gray between `STATUS_FADE_GRAY.trough` and `.settled` (the same dim gray as the idle caret), emitting `\x1b[38;2;r;g;bm`, so both the fade and the throb are brightness. Without truecolor there is no per-frame gray, so a separate `visible` flag — driven by the envelope alone, not the pulsing opacity — shows the glyph in the palette's muted role or leaves a blank column; the throb never blinks the fallback. With color off entirely a visible glyph is bare, preserving the caret column on a monochrome terminal. + +The running prompt refreshes at `STATUS_ANIMATION_INTERVAL_MS = 50` (~20 fps) so the throb moves every frame; the same tick keeps the 0.1 s-resolution elapsed text current, so no separate timing timer exists. + +Fade-out outlives the turn: on the running → non-running edge `beginFadeOut` hands the last rendered glyph to a `FadingStatus` whose own timer re-renders until the fade window elapses, then calls `clearStatus` and restores `>`. Teardown paths (dispose, agent-disposed, startup-failure) call `clearStatus` directly, stopping both the running and fading timers at once — no lingering fade. The glyph handed to the fade-out is the last live phase glyph (`runningStatus.lastGlyph`), not the ttft fallback the phase derivation returns once the closing turn's step has ended. + +The glyph character and its cell never change — only the gray brightness — so the caret column stays fixed across frames and across the caret↔glyph transitions. + +## Alternatives considered + +**Keep the accent color.** The pulse is wanted, but as a quiet gray matching the idle caret's tone, not a colored indicator; the accent is removed while the throb stays. + +**Hold steady while running (no throb).** A steady dim glyph was tried and rejected: a continuous pulse better conveys that the agent is actively working. The throb returns, in gray. + +**A non-zero floor that keeps the trough faintly visible.** Successive floors (0.45 → 0.15 → 0.02) each kept the dimmest point too visible to read as truly quiet; even 0.02 sat at gray ≈ 45, one step off the background. A floor of 0 with an explicit visibility threshold (`STATUS_FADE_MIN_OPACITY`) instead hides the glyph entirely at the bottom of each breath, so the trough is genuinely absent. Because the swell is a smooth cosine, the disappearance reads as a soft fade-out, not the hard on/off blink a low-but-nonzero gray toggle would give. + +**Pulse the non-truecolor fallback too.** SGR exposes only three intensity levels, too coarse for a smooth throb, and toggling the glyph on/off across the pulse would blink it. The fallback instead shows a steady muted glyph gated by the envelope; only truecolor terminals get the throb. + +## Consequences + +The running glyph reads as a quiet gray breath that swells from nothing to a dim mark and back the whole turn, matching the idle caret's tone, at the cost of a faster render tick (50 ms) while a turn is active or fading out; the diffing terminal only re-emits changed cells, so the extra frames are cheap. The fade-out means the indicator lingers ~300 ms after a turn completes. Snapshots run non-truecolor with a frozen clock, so they pin only the steady muted glyph (envelope-gated), not the throb; the truecolor invisible trough, the settled peak, a rising mid-frame, the fade-out, and the non-truecolor appear/disappear are pinned by unit tests in `tui.spec.ts`. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md new file mode 100644 index 0000000000..25bda3d549 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +[English](2026-07-27-tui-running-glyph-smooth-fade.md) | 中文 + +## Problem + +回合运行时,TUI 会把 `>` 提示符替换为阶段字形(`◍`/`✻`/`●`/`⚙`)。此前的迭代用强调蓝为其亮度做动画(先是离散 SGR 波,后是 truecolor 呼吸)——一个持续脉动的彩色指示器。期望的效果保留持续脉动以示正在工作,但改为安静的暗灰而非颜色,并在两端做平滑的淡入淡出。 + +## Decision + +运行字形是一种暗灰色,在回合开始时淡入,运行期间持续脉动,回合结束后淡出,随后恢复为普通的 `>` 光标。它从不使用强调色。 + +亮度是淡入淡出包络乘以运行脉冲。包络控制出现/消失,随渲染时钟在 `STATUS_FADE_MS = 300` 内线性变化:淡入为 `(now − startedAt)/FADE` 并做钳制,淡出为 `1 − (now − endedAt)/FADE`。`pulseLevel` 是在 `STATUS_PULSE_FLOOR`(0)与 1 之间、周期为 `STATUS_PULSE_PERIOD_MS = 1400` 的余弦,因此每次呼吸都从完全不可见涨到满亮再回落。交给 `fadeGlyph` 的 truecolor 不透明度为 `envelope × pulse`。 + +`fadeGlyph` 以该不透明度渲染。在 truecolor 下,低于 `STATUS_FADE_MIN_OPACITY`(0.12)时字形被完全隐藏——留出空白列——因此脉冲谷值消失,而非停留为接近背景的灰;在其之上,字形在 `STATUS_FADE_GRAY.trough` 与 `.settled`(与空闲光标相同的暗灰)之间插值出 24 位灰色,发出 `\x1b[38;2;r;g;bm`,因此淡入与脉冲都表现为亮度。没有 truecolor 时不存在逐帧灰度,因此用一个单独的 `visible` 标志——只由包络驱动,而非脉动的不透明度——以调色板 muted 角色显示字形或留出空白列;脉冲从不使回退闪烁。完全关闭颜色时,可见字形以裸字符呈现,在单色终端上保住光标列。 + +运行提示符以 `STATUS_ANIMATION_INTERVAL_MS = 50`(约 20 fps)刷新,使脉动逐帧移动;同一次 tick 也让 0.1 s 精度的耗时文本保持最新,因此不需要单独的计时器。 + +淡出会延续到回合之后:在运行 → 非运行的边沿,`beginFadeOut` 把最后渲染的字形交给一个 `FadingStatus`,其自有计时器持续重绘,直到渐变窗口结束,然后调用 `clearStatus` 并恢复 `>`。拆解路径(dispose、agent-disposed、启动失败)直接调用 `clearStatus`,一次性停止运行与淡出两个计时器——不会有残留的渐变。交给淡出的字形是最后一次的实时阶段字形(`runningStatus.lastGlyph`),而非收尾回合的步骤结束后阶段推导返回的 ttft 兜底字形。 + +字形字符及其单元格从不改变——只有灰色亮度变化——所以光标列在各帧之间以及光标↔字形的切换之间都保持固定。 + +## Alternatives considered + +**保留强调色。** 需要脉冲,但要用与空闲光标一致的安静灰色,而非彩色指示器;移除强调色,保留脉动。 + +**运行时保持稳定(不脉动)。** 曾试过稳定的暗色字形并被否决:持续脉动更能表明代理正在积极工作。脉动以灰色回归。 + +**用非零下限让谷值保持微弱可见。** 逐次下限(0.45 → 0.15 → 0.02)都让最暗点太可见,读不出真正的安静;即便 0.02 也停在灰度约 45,仅比背景高一档。改用下限 0 加显式可见阈值(`STATUS_FADE_MIN_OPACITY`),在每次呼吸的底部完全隐藏字形,使谷值真正缺席。由于涨落是平滑余弦,消失读作柔和的淡出,而非低而非零的灰度开关会带来的硬性开/关闪烁。 + +**让非 truecolor 回退也脉动。** SGR 只暴露三个强度档位,做平滑脉动太粗糙,而按脉冲开关字形会使其闪烁。回退改为由包络控制的稳定 muted 字形;只有 truecolor 终端获得脉动。 + +## Consequences + +代价是运行或淡出期间渲染 tick 更快(50 ms),换来的是运行字形整段回合读作一种从无涨到暗记号再回落的安静灰色呼吸,与空闲光标的色调一致;差分终端只重发变化的单元格,因此额外帧开销很低。淡出意味着指示器在回合结束后残留约 300 ms。快照以非 truecolor、冻结时钟运行,因此只钉住由包络控制的稳定 muted 字形,而非脉动;truecolor 的不可见谷值、稳定峰值、上升中间帧、淡出、以及非 truecolor 的出现/消失均由 `tui.spec.ts` 的单元测试钉住。 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 new file mode 100644 index 0000000000..820645dc95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.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-tui-tool-card-header.md +2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 +2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 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 new file mode 100644 index 0000000000..0868d8dcaf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +English | [中文](2026-07-27-tui-tool-card-header.zh.md) + +## Problem + +The TUI rendered each tool call as `{glyph} {title}`, where `title` was the presenter's fused verb-plus-detail string (`Read src/index.ts (1200-1360)`, `Edit files`, or a bash card's model description), bold and underlined in the status color. One flat slot carried the tool identity, the target, and the status at once, and the styling mixed bold, underline, and color inconsistently — the header read as noise, and which tool ran was not visually separable from what it operated on. + +## Decision + +The header is a fixed `{ring} Tool / ` frame in a single flat status color — no bold, no underline, no dim — so one color reads consistently across the whole row. `Tool` is a literal constant; `` is the raw tool name. The separator is ASCII `/`. The ring marker is `○` while the call is pending and `●` once it settles; the header color (warning pending / success ok / error) distinguishes pending from ok from error, so the same filled ring serves both settled states. + +The header carries exactly one optional extra: a bash (terminal) card's model-authored description, appended as a ` / ` segment (`● Tool / bash / Run the coverage gate`). No other tool contributes a header detail. + +Every tool-specific detail moves into the body block below the header. A non-terminal card's presenter title (`Read src/index.ts`, `Grep pattern`) becomes the first body line, unless it only repeats the tool name (the fallback presenter for a tool with no `presentCall`, or an unknown tool), which the header already shows. A terminal card keeps its command as the `$`-line. A diff card drops its title entirely — the per-file path headers and a change footer carry the meaning — and appends a dim `└ +A -R · N file(s)` footer summarizing added/removed line counts across the files. + +The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src/components/transcript.ts` and no presenter: the `Tool / ` frame derives the name TUI-side from the call's tool name, and the body-title relocation reuses the presenter title already returned. `presentation.ts` and every `presentCall`/`presentResult` are unchanged. + +## Alternatives considered + +**Bold the name to make it stand out.** Rejected: on terminals that render SGR-1 as the bright color variant, a bold green name reads as a different color from the rest of the green header — reintroducing the inconsistency the redesign removes. The name stands out by position in the fixed frame, not by weight. + +**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. + +## Consequences + +A tool call now shows its identity in one stable place, and status reads as one flat color per row, so a transcript of many calls scans as a column of `Tool / ` rather than a wall of mixed-styled verb strings. The cost is one extra body line for non-terminal tools (the relocated title) and the loss of the earlier redundancy-suppression that omitted a diff's per-file path when the header already named it — the header no longer names any path, so every diff prints its path once. Because the change is confined to `ToolCardComponent`, other UI bridges (ACP, JSON-RPC) keep their own tool-call presentation; the `Tool / ` shape is TUI-local and not part of any cross-package contract. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the new header (`Tool / `), the dropped diff title, the relocated generic title, and the `· N file(s)` footer. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` — rendered through the real assembled TUI and a pseudo-terminal — were re-recorded and show the new cards for read, bash (described and undescribed), edit, and the other tools. 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 new file mode 100644 index 0000000000..71db5fc6fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +[English](2026-07-27-tui-tool-card-header.md) | 中文 + +## Problem + +TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 presenter 拼接的「动词加细节」字符串(`Read src/index.ts (1200-1360)`、`Edit files`,或 bash 卡片的模型描述),以状态色加粗并加下划线显示。单一扁平的槽位同时承载了工具身份、操作对象和状态,而样式又混用了加粗、下划线和颜色,前后不一致——表头读起来像噪声,「运行了哪个工具」在视觉上与「它操作了什么」无法区分。 + +## Decision + +表头是固定的 `{ring} Tool / ` 框架,采用单一扁平的状态色——不加粗、不加下划线、不变暗——因此整行的颜色保持一致。`Tool` 是字面常量;`` 是原始工具名。分隔符是 ASCII 的 `/`。环形标记在调用挂起时为 `○`,落定后为 `●`;表头颜色(挂起用 warning、成功用 success、错误用 error)区分挂起、成功与错误,因此同一个实心环可同时服务于两种落定状态。 + +表头只携带一个可选的额外内容:bash(终端)卡片由模型撰写的描述,作为 ` / ` 段追加(`● Tool / bash / Run the coverage gate`)。其他工具都不向表头贡献细节。 + +每一项工具专属的细节都移入表头下方的正文块。非终端卡片的 presenter 标题(`Read src/index.ts`、`Grep pattern`)成为正文第一行,除非它只是重复工具名(无 `presentCall` 的工具的兜底 presenter,或未知工具),此时表头已经显示过。终端卡片保留其命令作为 `$` 行。diff 卡片完全弃用其标题——由各文件的路径表头与一条变更页脚承载含义——并追加一条变暗的 `└ +A -R · N file(s)` 页脚,汇总各文件增删的行数。 + +本次改版仅限 TUI。它改动 `packages/ui/tui/src/components/transcript.ts` 中的 `ToolCardComponent`,不触碰任何 presenter:`Tool / ` 框架在 TUI 侧从调用的工具名推导出名称,正文标题的迁移则复用 presenter 已返回的标题。`presentation.ts` 以及每一个 `presentCall`/`presentResult` 均保持不变。 + +## Alternatives considered + +**把工具名加粗使其突出。** 已否决:在把 SGR-1 渲染为亮色变体的终端上,加粗的绿色工具名读起来与其余绿色表头是不同的颜色——重新引入了改版本要消除的不一致。工具名靠它在固定框架中的位置突出,而非靠字重。 + +**把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 + +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 + +## Consequences + +工具调用现在把身份显示在一个稳定的位置,状态每行读作一种扁平色,于是许多调用的记录扫读起来是一列 `Tool / `,而非一堵混合样式的动词字符串之墙。代价是非终端工具多出一行正文(迁移过来的标题),以及丢失了先前的冗余抑制——当表头已命名路径时省略 diff 的各文件路径;如今表头不再命名任何路径,因此每个 diff 都会把路径打印一次。由于改动局限于 `ToolCardComponent`,其他 UI 桥(ACP、JSON-RPC)保留各自的工具调用呈现;`Tool / ` 的形态是 TUI 局部的,不属于任何跨包契约。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定了新表头(`Tool / `)、弃用的 diff 标题、迁移后的 generic 标题以及 `· N file(s)` 页脚。`packages/ui/tui/tests/snapshots/` 与 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照——经由真实组装的 TUI 与伪终端渲染——已重新录制,展示了 read、bash(有描述与无描述)、edit 及其他工具的新卡片。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml new file mode 100644 index 0000000000..6575dbed79 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.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/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md +2026-07-27-copyable-transcript-no-gutter-bar.md: 659b7d2f2f85bf7efe6b1006a2c61a14f3044560 +2026-07-27-copyable-transcript-no-gutter-bar.zh.md: 5c43169ba352ef1fef73ecf2188a32304aba9c26 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md new file mode 100644 index 0000000000..659b7d2f2f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md @@ -0,0 +1,34 @@ +# Agent Note: Copyable TUI transcript without gutter bars + +Status: implemented + +English | [中文](2026-07-27-copyable-transcript-no-gutter-bar.zh.md) + +## Problem + +The TUI grouped user prompts and tool cards behind a colored left-gutter bar (`▌ `) prepended to every body line, and indented assistant and system blocks by one column. Both are per-line prefixes: a terminal mouse drag-select over the scrollback captures the leading `▌ ` or the leading space on each line, so copy-paste of a message, a tool's output, or a code block pulls in decoration the user must strip by hand. The bar was the transcript's only per-message separator, so it could not simply be dropped without another way to tell messages apart. + +## Decision + +The scrollback carries no per-line prefix. Messages are separated only by a bold, underlined role header in the role color and blank-line spacing, both of which the terminal already inserts around each block. The underline gives each role a distinct visual band without a background fill, so it reads on any terminal theme and never enters the clipboard: + +- User and steering prompts (`UserMessageComponent`) are a plain `Container`: a bold, underlined accent `You` / `Steering` header line (via the shared `messageHeader` helper), then the prompt body at column 0. +- Assistant blocks render a bold, underlined `Assistant` header, then reasoning and text at column 0, with the timing line at the end of the block (the former `paddingX = 1` indent is gone). +- Tool cards drop the `GutterBox` wrapper. The card status (pending / error / success) colors the whole title line — the status glyph (`◌` / `✕` / `✓`) plus the title text share one color, bold and underlined to match the role headers — instead of a colored bar beside an uncolored title. The body renders unprefixed; body lines still pass through `Text` at the terminal width so overlong raw tool output wraps rather than overflowing. +- The `GutterBox` class is deleted; nothing else used it. + +A drag-select over any of these regions now copies exactly the message text. + +## Alternatives considered + +- **Keep the bar only on user messages, drop it on tool cards** — leaves tool output, the most-copied region, still polluted. Rejected: the goal is a wholly copyable transcript. +- **A single top rule or bar on the header line only** — the body copies clean, but selecting the header still captures a glyph, and it reintroduces a decoration character for no distinguishing gain over the underlined role header. +- **Indent grouped bodies instead of a bar** — leading spaces still enter the clipboard, so it does not solve the copy problem; explicitly ruled out. +- **A filled background band on the header** (reverse video, or a 256-color muted background) — gives each role a strong color block, but the saturated ANSI fill reads as too heavy and the 256-color shades are fixed rather than theme-remapped. The underline gives per-role distinction with a far lighter footprint. + +## Consequences + +- Copy-paste from the scrollback is clean with no user post-processing. This was the motivating win. +- The transcript is flatter than the gutter-bar layout, but each role's bold, underlined header in the role color plus blank-line spacing keeps message boundaries clear without any left-edge fill. Tool-card status stays legible through the colored, underlined glyph and title. +- Box-drawing borders (`│`) on transient overlays — status panel, model selector, resume list — are untouched. They are not scrollback message content and are rarely copied. +- The affected keyless TUI `*.expected.txt` snapshots were re-recorded by fixture replay (no API key needed; the recorded LLM sessions are unchanged, only the render differs). Interactive boot and a round-trip prompt were verified in tmux. diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md new file mode 100644 index 0000000000..5c43169ba3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 无 gutter bar 的可复制 TUI transcript + +Status: implemented + +[English](2026-07-27-copyable-transcript-no-gutter-bar.md) | 中文 + +## Problem + +TUI 此前把用户提示词和工具卡片分组在一条彩色左侧 gutter bar(`▌ `)之后,该竖条被逐行加在每一行正文前面,并把 assistant 与系统块整体缩进一列。两者都是逐行前缀:在 transcript 上用鼠标框选时,每一行开头的 `▌ ` 或前导空格都会被一并选中,因此复制一条消息、一段工具输出或一个代码块时都会带上装饰字符,用户必须手动清理。该竖条又是 transcript 中唯一的逐条消息分隔标记,所以不能在没有其他区分方式的情况下直接删掉。 + +## Decision + +transcript 不再带任何逐行前缀。消息仅通过以角色色渲染的粗体带下划线角色标题和空行分隔,而这两者本就由终端在每个块前后自动插入。下划线让每个角色获得清晰的视觉分带,且无需背景填充,因此在任何终端配色下都可读,也绝不会进入剪贴板: + +- 用户提示词与 steering 提示词(`UserMessageComponent`)改为普通 `Container`:一行粗体带下划线的强调色 `You` / `Steering` 标题(经共享的 `messageHeader` 辅助函数生成),随后是位于第 0 列的提示词正文。 +- Assistant 块渲染一行粗体带下划线的 `Assistant` 标题,随后 reasoning 与文本均在第 0 列渲染,timing 行位于块末尾(原先的 `paddingX = 1` 缩进已移除)。 +- 工具卡片去掉 `GutterBox` 包装层。卡片状态(进行中 / 错误 / 成功)对整行标题着色——状态字形(`◌` / `✕` / `✓`)与标题文本共用一种颜色,并同角色标题一样加粗且带下划线——而不再是未着色标题旁的一条彩色竖条。正文无前缀渲染;正文行仍按终端宽度经 `Text` 处理,使过长的原始工具输出换行而非溢出。 +- `GutterBox` 类被删除;没有其他地方使用它。 + +现在对上述任一区域框选,复制得到的正是消息文本本身。 + +## Alternatives considered + +- **仅在用户消息上保留竖条、在工具卡片上去掉** —— 会让最常被复制的工具输出仍然带有污染。已否决:目标是让整个 transcript 都可复制。 +- **仅在标题行上加一条顶部横线或竖条** —— 正文复制干净,但选中标题时仍会带上一个字形,且相比带下划线的角色标题并未带来额外的区分收益,却重新引入了装饰字符。 +- **用缩进代替竖条对分组正文缩进** —— 前导空格仍会进入剪贴板,无法解决复制问题;已明确排除。 +- **在标题上使用填充背景带**(反色,或 256 色柔和背景)—— 能给每个角色一块强烈的色块,但饱和的 ANSI 填充观感过重,且 256 色是固定色而非随主题重映射。下划线以远更轻的方式提供了同样的逐角色区分。 + +## Consequences + +- 从 transcript 复制粘贴无需用户做任何后处理。这正是本次改动的核心收益。 +- transcript 比 gutter bar 布局更扁平,但每个角色以角色色渲染的粗体带下划线标题加空行分隔,无需任何左缘填充即可让消息边界保持清晰。工具卡片状态仍通过彩色带下划线的字形与标题保持可读。 +- 临时浮层(状态面板、模型选择器、恢复列表)上的制表符边框(`│`)保持不变。它们不属于 transcript 消息内容,且很少被复制。 +- 受影响的 keyless TUI `*.expected.txt` 快照均通过 fixture 回放重新记录(无需 API 密钥;所记录的 LLM 会话未变,仅渲染不同)。交互式启动与一次往返提示已在 tmux 中验证。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 4283668189..a87812ce00 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh/tui */ +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -62,6 +63,7 @@ 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()) + 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. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c45cb73de3..64643d583b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1795,7 +1795,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:302`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6785e5f983..f812612521 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1902,7 +1902,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:207`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 9ffee44378..0d8f454204 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,63 +1,54 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 +cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the bash tool to" style 1-20 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " The user wants me to run a simple bash command and then reply with \"DONE\". " - style 1-74 fg=bright-black italic -11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ echo TERMINAL_OK " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -14| "▌ Echo TERMINAL_OK to verify terminal access " - style 0-0 fg=green - style 2-43 fg=bright-black -15| "▌ TERMINAL_OK " - style 0-0 fg=green -16| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -17| "▌ " - style 0-0 fg=green -18| -19| " Reasoning " - style 1-9 fg=bright-black italic -20| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 1-91 fg=bright-black italic -21| -22| " Assistant " - style 1-9 fg=bright-magenta bold -23| " DONE " -24| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -25| " " - style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% 3% contex" - style 0-88 dim - style 91-99 dim -28-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 fg=bright-black italic +9| "The user wants me to run a simple bash command and then reply with \"DONE\". " + style 0-73 fg=bright-black italic +10| +11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" + style 0-57 fg=green +12| "$ echo TERMINAL_OK " + style 0-17 fg=cyan +13| "TERMINAL_OK " +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 fg=bright-black italic +19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 0-90 fg=bright-black italic +20| "DONE " +21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +22| +23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" + style 0-46 fg=bright-blue bold + style 49-65 fg=bright-black + style 68-88 fg=bright-black + style 91-99 fg=bright-black +24| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +25-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index aad4b2cd50..248ea2f4e7 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -1,65 +1,57 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=26 bufferRow=26 +cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " - style 0-0 fg=bright-blue - style 79-99 fg=cyan -7| "▌ '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " - style 0-0 fg=bright-blue - style 2-74 fg=cyan -8| "▌ number of lines in its output. Reply with just that number and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 1-99 fg=bright-black italic -13| " command, then returns only the number of lines in its output. " - style 1-61 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Count lines in seq/awk output " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-32 bold -17| "▌ 200 " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The result is 200 lines. The user wants me to reply with just that number and stop. " - style 1-83 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " 200 " -25| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -26| " " - style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" - style 0-93 dim - style 96-99 dim -29-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " + style 77-99 fg=cyan +6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " + style 0-72 fg=cyan +7| "number of lines in its output. Reply with just that number and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " + style 0-99 fg=bright-black italic +12| "command, then returns only the number of lines in its output. " + style 0-60 fg=bright-black italic +13| +14| "● Tool / run_code" + style 0-16 fg=green +15| "Count lines in seq/awk output " +16| "200 " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The result is 200 lines. The user wants me to reply with just that number and stop. " + style 0-82 fg=bright-black italic +22| "200 " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24| +25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-93 fg=bright-black + style 96-99 fg=bright-black +26| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +27-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 8dd0c37da1..45879f889f 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,136 +1,140 @@ -terminal 100x36 buffer=normal length=64 base=28 viewport=28 +terminal 100x36 buffer=normal length=62 base=26 viewport=26 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=61 +cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " - style 0-0 fg=bright-blue - style 65-77 fg=cyan - style 92-99 fg=cyan -7| "▌ CODE_TWO. Inside that same program, console.log exactly captured output, then return the two " - style 0-0 fg=bright-blue - style 2-9 fg=cyan - style 58-72 fg=cyan -8| "▌ outputs joined with a plus sign. Reply with that joined string only and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that: " - style 1-36 fg=bright-black italic - style 37-44 fg=cyan - style 45-58 fg=bright-black italic -13| " 1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 1-3 fg=bright-blue - style 4-9 fg=bright-black italic - style 10-13 fg=cyan - style 14-38 fg=bright-black italic - style 39-51 fg=cyan - style 52-63 fg=bright-black italic - style 64-76 fg=cyan -14| " 2. console.log exactly captured output " - style 1-3 fg=bright-blue - style 4-14 fg=cyan - style 15-23 fg=bright-black italic - style 24-38 fg=cyan -15| " 3. Returns the two outputs joined with a plus sign " - style 1-3 fg=bright-blue - style 4-50 fg=bright-black italic -16| " " -17| " Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 1-38 fg=bright-black italic - style 39-42 fg=cyan - style 43-99 fg=bright-black italic -18| " extract the stdout text from each call. " - style 1-39 fg=bright-black italic -19| " " -20| " Looking at the bash output type: " - style 1-32 fg=bright-black italic +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 63-75 fg=cyan + style 90-99 fg=cyan +6| "CODE_TWO. Inside that same program, console.log exactly captured output, then return the two outputs" + style 0-7 fg=cyan + style 56-70 fg=cyan +7| "joined with a plus sign. Reply with that joined string only and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that: " + style 0-35 fg=bright-black italic + style 36-43 fg=cyan + style 44-57 fg=bright-black italic +12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " + style 0-2 fg=bright-blue + style 3-8 fg=bright-black italic + style 9-12 fg=cyan + style 13-37 fg=bright-black italic + style 38-50 fg=cyan + style 51-62 fg=bright-black italic + style 63-75 fg=cyan +13| "2. console.log exactly captured output " + style 0-2 fg=bright-blue + style 3-13 fg=cyan + style 14-22 fg=bright-black italic + style 23-37 fg=cyan +14| "3. Returns the two outputs joined with a plus sign " + style 0-2 fg=bright-blue + style 3-49 fg=bright-black italic +15| " " +16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " + style 0-37 fg=bright-black italic + style 38-41 fg=cyan + style 42-99 fg=bright-black italic +17| "extract the stdout text from each call. " + style 0-38 fg=bright-black italic +18| " " +19| "Looking at the bash output type: " + style 0-31 fg=bright-black italic +20| " " 21| " " -22| " ``` " - style 1-3 dim -23| " { " -24| " kind: \"foreground\"; " -25| " exitCode: number | null; " -26| " signal: string | null; " -27| " timedOut: boolean; " -28| " aborted: boolean; " -29| " timeoutMs: number; " -30| " stdout: { " -31| " text: string; " -32| " truncated: boolean; " -33| " spillPath?: string; " -34| " }; " -35| " stderr: { ... }; " -36| " sandbox?: { ... }; " -37| " } " -38| " ``` " - style 1-3 dim -39| " " -40| " So I need to access .stdout.text from each result. " - style 1-20 fg=bright-black italic - style 21-32 fg=cyan - style 33-50 fg=bright-black italic -41| " " -42| " Let me write the program. " - style 1-25 fg=bright-black italic -43| -44| "▌ " - style 0-0 fg=green -45| "▌ ✓ Call bash twice, log, join outputs " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-37 bold -46| "▌ captured output " - style 0-0 fg=green -47| "▌ CODE_ONE+CODE_TWO " - style 0-0 fg=green -48| "▌ " - style 0-0 fg=green -49| -50| " Reasoning " - style 1-9 fg=bright-black italic -51| " The program ran successfully. The output shows: " - style 1-47 fg=bright-black italic -52| " - captured output (from console.log) " - style 1-2 fg=bright-blue - style 3-17 fg=cyan - style 18-36 fg=bright-black italic -53| " - CODE_ONE+CODE_TWO (the returned joined string) " - style 1-2 fg=bright-blue - style 3-19 fg=cyan - style 20-48 fg=bright-black italic +22| " { " + style 2-2 fg=cyan +23| " kind: \"foreground\"; " + style 2-22 fg=cyan +24| " exitCode: number | null; " + style 2-27 fg=cyan +25| " signal: string | null; " + style 2-25 fg=cyan +26| " timedOut: boolean; " + style 2-21 fg=cyan +27| " aborted: boolean; " + style 2-20 fg=cyan +28| " timeoutMs: number; " + style 2-21 fg=cyan +29| " stdout: { " + style 2-12 fg=cyan +30| " text: string; " + style 2-18 fg=cyan +31| " truncated: boolean; " + style 2-24 fg=cyan +32| " spillPath?: string; " + style 2-24 fg=cyan +33| " }; " + style 2-5 fg=cyan +34| " stderr: { ... }; " + style 2-19 fg=cyan +35| " sandbox?: { ... }; " + style 2-21 fg=cyan +36| " } " + style 2-2 fg=cyan +37| " " +38| " " +39| "So I need to access .stdout.text from each result. " + style 0-19 fg=bright-black italic + style 20-31 fg=cyan + style 32-49 fg=bright-black italic +40| " " +41| "Let me write the program. " + style 0-24 fg=bright-black italic +42| +43| "● Tool / run_code" + style 0-16 fg=green +44| "Call bash twice, log, join outputs " +45| "captured output " +46| "CODE_ONE+CODE_TWO " +47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +48| +49| "Assistant " + style 0-8 fg=bright-magenta bold underline +50| "Reasoning " + style 0-8 fg=bright-black italic +51| "The program ran successfully. The output shows: " + style 0-46 fg=bright-black italic +52| "- captured output (from console.log) " + style 0-1 fg=bright-blue + style 2-16 fg=cyan + style 17-35 fg=bright-black italic +53| "- CODE_ONE+CODE_TWO (the returned joined string) " + style 0-1 fg=bright-blue + style 2-18 fg=cyan + style 19-47 fg=bright-black italic 54| " " -55| " The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 1-99 fg=bright-black italic -56| " CODE_ONE+CODE_TWO. " - style 1-17 fg=cyan - style 18-18 fg=bright-black italic -57| -58| " Assistant " - style 1-9 fg=bright-magenta bold -59| " CODE_ONE+CODE_TWO " -60| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -61| " " - style 1-1 inverse -62| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -63| "deepseek-v4-flash /workspace/project ↑182 ↓446 cache 98% 4% context tools:c" - style 0-78 dim - style 81-99 dim +55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " + style 0-99 fg=bright-black italic +56| "CODE_ONE+CODE_TWO. " + style 0-16 fg=cyan + style 17-17 fg=bright-black italic +57| "CODE_ONE+CODE_TWO " +58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +59| +60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-78 fg=bright-black + style 81-90 fg=bright-black +61| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a3739deefa..9f420d2bf5 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,106 +1,93 @@ -terminal 100x36 buffer=normal length=48 base=12 viewport=12 +terminal 100x36 buffer=normal length=57 base=21 viewport=21 lifecycle started=1 stopped=0 progress=inactive title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=45 +cursor hidden column=7 viewportRow=35 bufferRow=56 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Run this advanced flow exactly" style 1-30 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " - style 0-0 fg=bright-blue -7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " - style 0-0 fg=bright-blue -8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " - style 0-0 fg=bright-blue -9| "▌ reply with exactly ADVANCED_ACP_OK. " - style 0-0 fg=bright-blue -10| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code" +6| "to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn " +7| "child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with " +8| "exactly ADVANCED_ACP_OK. " +9| +10| "Assistant " + style 0-8 fg=bright-magenta bold underline 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Mount plugin into live cordis runtime " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-40 bold -14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green +12| "● Tool / cordis_mount" + style 0-20 fg=green +13| "Mount plugin into live cordis runtime " +14| "mounted dyn-1 (plugin \"snapshot-marker\", state: active) " +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim 16| -17| "▌ " - style 0-0 fg=green -18| "▌ ✓ Verify the dynamically mounted marker service " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-48 bold -19| "▌ ## dynamic " - style 0-0 fg=green -20| "▌ - dyn-1: snapshot-marker [active] " - style 0-0 fg=green -21| "▌ " - style 0-0 fg=green -22| -23| "▌ " - style 0-0 fg=green -24| "▌ ✓ subagent " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-11 bold -25| "▌ DIRECT_CHILD_OK " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| -28| "▌ " - style 0-0 fg=green -29| "▌ ✓ workflow: advanced-acp-snapshot " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-34 bold -30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " - style 0-0 fg=green -31| "▌ Return value: " - style 0-0 fg=green -32| "▌ { " - style 0-0 fg=green -33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " - style 0-0 fg=green -34| "▌ } " - style 0-0 fg=green -35| "▌ " - style 0-0 fg=green -36| -37| "▌ " - style 0-0 fg=green -38| "▌ ✓ Unmount dyn-1 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " - style 0-0 fg=green -40| "▌ " - style 0-0 fg=green -41| -42| " Assistant " - style 1-9 fg=bright-magenta bold -43| " ADVANCED_ACP_OK " -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" - style 0-90 dim - style 93-99 dim +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| +19| "● Tool / run_code" + style 0-16 fg=green +20| "Verify the dynamically mounted marker service " +21| " " +22| "dynamic " + style 0-6 fg=bright-blue bold +23| " " +24| "- dyn-1: snapshot-marker [active] " + style 0-1 fg=bright-blue +25| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +26| +27| "Assistant " + style 0-8 fg=bright-magenta bold underline +28| +29| "● Tool / subagent" + style 0-16 fg=green +30| "DIRECT_CHILD_OK " +31| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +32| +33| "Assistant " + style 0-8 fg=bright-magenta bold underline +34| +35| "● Tool / workflow" + style 0-16 fg=green +36| "workflow: advanced-acp-snapshot " +37| "workflow \"advanced-acp-snapshot\" completed (1 agent). " +38| "Return value: " +39| "{ " +40| " \"reply\": \"WORKFLOW_CHILD_OK\" " +41| "} " +42| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +43| +44| "Assistant " + style 0-8 fg=bright-magenta bold underline +45| +46| "● Tool / cordis_unmount" + style 0-22 fg=green +47| "Unmount dyn-1 " +48| "unmounted dyn-1 (plugin \"snapshot-marker\") " +49| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +50| +51| "Assistant " + style 0-8 fg=bright-magenta bold underline +52| "ADVANCED_ACP_OK " +53| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +54| +55| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-90 fg=bright-black + style 93-99 fg=bright-black +56| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 48adbbb11a..c704cb2399 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,96 +1,80 @@ -terminal 100x36 buffer=normal length=45 base=9 viewport=9 +terminal 100x36 buffer=normal length=43 base=7 viewport=7 lifecycle started=1 stopped=0 progress=inactive title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=42 +cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the workflow tool exactly" style 1-29 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " - style 0-0 fg=bright-blue -7| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " - style 0-0 fg=bright-blue -8| "▌ phase('Run') " - style 0-0 fg=bright-blue -9| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " - style 0-0 fg=bright-blue -10| "▌ return { reply } " - style 0-0 fg=bright-blue -11| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " - style 0-0 fg=bright-blue -12| "▌ other tool. " - style 0-0 fg=bright-blue -13| "▌ " - style 0-0 fg=bright-blue -14| -15| " Reasoning " - style 1-9 fg=bright-black italic -16| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 1-99 fg=bright-black italic -17| " follow the instructions: " - style 1-24 fg=bright-black italic -18| " " -19| " 1. args omitted (so I don't include it) " - style 1-3 fg=bright-blue - style 4-39 fg=bright-black italic -20| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 1-3 fg=bright-blue - style 4-82 fg=bright-black italic -21| " 3. script = as given verbatim " - style 1-3 fg=bright-blue - style 4-29 fg=bright-black italic -22| " 4. After it returns, reply with \"WORKFLOW_DONE\" " - style 1-3 fg=bright-blue - style 4-47 fg=bright-black italic -23| " " -24| " Let me do exactly that. " - style 1-23 fg=bright-black italic -25| -26| "▌ " - style 0-0 fg=green -27| "▌ ✓ workflow: snapshot-flow " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -28| "▌ workflow \"snapshot-flow\" completed (1 agent). " - style 0-0 fg=green -29| "▌ Return value: " - style 0-0 fg=green -30| "▌ { " - style 0-0 fg=green -31| "▌ \"reply\": \"WF_CHILD_OK\" " - style 0-0 fg=green -32| "▌ } " - style 0-0 fg=green -33| "▌ " - style 0-0 fg=green -34| -35| " Reasoning " - style 1-9 fg=bright-black italic -36| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 1-99 fg=bright-black italic -37| " \"WORKFLOW_DONE\" and stop. " - style 1-25 fg=bright-black italic -38| -39| " Assistant " - style 1-9 fg=bright-magenta bold -40| " WORKFLOW_DONE " -41| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -42| " " - style 1-1 inverse -43| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% 3% context " - style 0-86 dim - style 89-99 dim +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " +6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " +7| "phase('Run') " +8| "const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " +9| "return { reply } " +10| "After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other " +11| "tool. " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| "Reasoning " + style 0-8 fg=bright-black italic +15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 0-99 fg=bright-black italic +16| "follow the instructions: " + style 0-23 fg=bright-black italic +17| " " +18| "1. args omitted (so I don't include it) " + style 0-2 fg=bright-blue + style 3-38 fg=bright-black italic +19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 0-2 fg=bright-blue + style 3-81 fg=bright-black italic +20| "3. script = as given verbatim " + style 0-2 fg=bright-blue + style 3-28 fg=bright-black italic +21| "4. After it returns, reply with \"WORKFLOW_DONE\" " + style 0-2 fg=bright-blue + style 3-46 fg=bright-black italic +22| " " +23| "Let me do exactly that. " + style 0-22 fg=bright-black italic +24| +25| "● Tool / workflow" + style 0-16 fg=green +26| "workflow: snapshot-flow " +27| "workflow \"snapshot-flow\" completed (1 agent). " +28| "Return value: " +29| "{ " +30| " \"reply\": \"WF_CHILD_OK\" " +31| "} " +32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +33| +34| "Assistant " + style 0-8 fg=bright-magenta bold underline +35| "Reasoning " + style 0-8 fg=bright-black italic +36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 0-99 fg=bright-black italic +37| "\"WORKFLOW_DONE\" and stop. " + style 0-24 fg=bright-black italic +38| "WORKFLOW_DONE " +39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +40| +41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" + style 0-44 fg=bright-blue bold + style 47-63 fg=bright-black + style 66-86 fg=bright-black + style 89-98 fg=bright-black +42| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 099bf63b81..3aa6476f01 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,70 +1,62 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=33 +cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Reply with exactly the word:" style 1-28 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black +4| "Entering plan mode (applies from the next step). Use /plan off to leave. " + style 0-71 fg=bright-black 5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 1-72 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " ONE " +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Reply with exactly the word: ONE. No tools. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 0-71 fg=bright-black italic +12| "ONE " +13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +14| +15| "Leaving plan mode (applies from the next step). " + style 0-46 fg=bright-black 16| -17| " Leaving plan mode (applies from the next step). " - style 1-47 fg=bright-black -18| -19| " Context · plan-mode " - style 1-19 dim -20| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Reply with exactly the word: TWO. No tools. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 1-68 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " TWO " -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| " " - style 1-1 inverse -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co" - style 0-92 dim - style 95-99 dim +17| "Context · plan-mode " + style 0-18 dim +18| "The user switched this session back to the default mode. " + style 0-55 fg=bright-black +19| +20| "You " + style 0-2 fg=bright-blue bold underline +21| "Reply with exactly the word: TWO. No tools. " +22| +23| "Assistant " + style 0-8 fg=bright-magenta bold underline +24| "Reasoning " + style 0-8 fg=bright-black italic +25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 0-67 fg=bright-black italic +26| "TWO " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" + style 0-51 fg=bright-blue bold + style 54-70 fg=bright-black + style 73-92 fg=bright-black + style 95-99 fg=bright-black +30| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 70fa5b173a..3d99c45758 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,81 +1,52 @@ -terminal 100x36 buffer=normal length=37 base=1 viewport=1 +terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=34 +cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the read tool twice" style 1-23 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ Read a.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -11| "▌ /workspace/project/a.txt " - style 0-0 fg=green -12| "▌ file " - style 0-0 fg=green -13| "▌ " - style 0-0 fg=green -14| "▌ 1: alpha " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -17| "▌ " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Read b.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -22| "▌ /workspace/project/b.txt " - style 0-0 fg=green -23| "▌ file " - style 0-0 fg=green -24| "▌ " - style 0-0 fg=green -25| "▌ 1: beta " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -28| "▌ " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| " Assistant " - style 1-9 fg=bright-magenta bold -32| " DONE " -33| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -34| " " - style 1-1 inverse -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% 3% context t" - style 0-84 dim - style 87-99 dim +9| "● Tool / read" + style 0-12 fg=green +10| "Read a.txt " +11| "1: alpha " +12| " " +13| "(End of file - total 1 lines) " +14| +15| "● Tool / read" + style 0-12 fg=green +16| "Read b.txt " +17| "1: beta " +18| " " +19| "(End of file - total 1 lines) " +20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +21| +22| "Assistant " + style 0-8 fg=bright-magenta bold underline +23| "DONE " +24| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +25| +26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" + style 0-47 fg=bright-blue bold + style 50-66 fg=bright-black + style 69-84 fg=bright-black + style 87-96 fg=bright-black +27| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +28-35| diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index ec38d43c99..9dafcf576d 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,57 +1,48 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=31 bufferRow=31 +cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the todo_write tool to" style 1-26 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " - style 0-0 fg=bright-blue -7| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " - style 0-0 fg=bright-blue -8| "▌ reply with the single word DONE and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 1-99 fg=bright-black italic -13| " specified statuses, then reply with \"DONE\". " - style 1-43 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Update todo list " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -17| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 1-95 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " DONE " -25| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " +6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " +7| "reply with the single word DONE and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 0-99 fg=bright-black italic +12| "specified statuses, then reply with \"DONE\". " + style 0-42 fg=bright-black italic +13| +14| "● Tool / todo_write" + style 0-18 fg=green +15| "Update todo list " +16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 0-94 fg=bright-black italic +22| "DONE " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24-25| 26| "Plan" style 0-3 fg=bright-blue bold 27| " ● read the code" @@ -60,13 +51,13 @@ buffer style 2-2 dim 29| " ○ run the tests" style 2-2 dim -30| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -31| " " - style 1-1 inverse -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% 3% context tools:" - style 0-79 dim - style 82-99 dim -34-35| +30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-79 fg=bright-black + style 82-91 fg=bright-black +31| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +32-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 1cbe6c478e..6aa1f428a3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -72,7 +72,14 @@ async function seedResumeSession(cwd: string): Promise { } /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ -async function readLoggedSystemPrompt(cwd: string): Promise { +interface LoggedRequestHeader { + /** The system prompt string the launcher sends. */ + system: string + /** The baked session-prefix messages (skill catalog, workspace context) serialized to text. */ + prefix: string +} + +async function readLoggedRequestHeader(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') const entries = await readdir(sessionsDir, { recursive: true }) // A single keyless run writes one session log; the source section is global, so any log carries it. @@ -80,8 +87,16 @@ async function readLoggedSystemPrompt(cwd: string): Promise { if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) for (const line of lines) { - const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } } - if (event.type === 'request/header') return event.data.header?.system ?? '' + const event = JSON.parse(line) as { + type: string + data: { header?: { system?: string; messagePrefix?: unknown } } + } + if (event.type === 'request/header') { + return { + system: event.data.header?.system ?? '', + prefix: JSON.stringify(event.data.header?.messagePrefix ?? []), + } + } } throw new Error(`session log ${logRelPath} has no request/header event`) } @@ -176,6 +191,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') + expect(output).toContain('System prompt') + expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(output).toContain('Registered tools') + expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -208,6 +227,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { { waitFor: 'Scripted skill body received.', send: '/exit\r' }, ], }) + expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -342,11 +362,13 @@ 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('tells the model where its own source lives, in the system prompt it sends', async () => { + 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. + // The source-path line is a system-prompt section; the bundled skills reach the + // model through the session-prefix catalog, so each assertion targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let loggedSystem = '' + let header: LoggedRequestHeader = { system: '', prefix: '' } await smoke({ label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', @@ -358,8 +380,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], - inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, + inspect: async (cwd) => { header = await readLoggedRequestHeader(cwd) }, }) - expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(header.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(header.prefix).toContain('- `dsh-customize`: Customize a dsh installation. Use before any requested change that potentailly impacts the checkout that powers the current DSH process or installed `dsh` command, including code, docs, skills, configuration, tests, commit history, or PR-branch updates; do not edit the personal staging checkout directly.') + expect(header.prefix).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback checkout. Use when the user asks to update or upgrade DSH.') + expect(header.prefix).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..ae4919b129 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -2,7 +2,7 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -25,7 +25,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -225,6 +225,7 @@ async function mountScenarioContext( await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped // config that omits it. The other scenarios prove the default todo-free composition. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) @@ -262,6 +263,7 @@ interface ScenarioResult { } async function runScenario(scenario: Scenario): Promise { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime()) const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) @@ -294,7 +296,7 @@ async function runScenario(scenario: Scenario): Promise { const agent: Agent = handle.agent controller = createTuiChat(ctx, { sessionId: 'main-session', - color: true, + theme: { color: true }, showReasoning: true, title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, @@ -404,6 +406,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + clock.mockRestore() } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a47d6e337b..14f473282b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2443,58 +2443,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSchema', declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n}', }, - { - name: 'TuiComponent', - declaration: 'export interface TuiComponent {\n render(width: number): string[];\n handleInput?(data: string): void;\n wantsKeyRelease?: boolean;\n invalidate(): void;\n}', - }, - { - name: 'TuiFocusable', - declaration: 'export interface TuiFocusable {\n focused: boolean;\n}', - }, - { - name: 'TuiOverlayAnchor', - declaration: 'export type TuiOverlayAnchor = \'center\' | \'top-left\' | \'top-right\' | \'bottom-left\' | \'bottom-right\' | \'top-center\' | \'bottom-center\' | \'left-center\' | \'right-center\';', - }, - { - name: 'TuiOverlayCloseReason', - declaration: 'export type TuiOverlayCloseReason = \'closed\' | \'aborted\' | \'owner-disposed\' | \'tui-disposed\' | \'error\';', - }, - { - name: 'TuiOverlayHost', - declaration: 'export interface TuiOverlayHost {\n readonly signal: AbortSignal;\n readonly viewport: TuiViewport;\n readonly theme: TuiTheme;\n display(value: string): string;\n invalidate(): void;\n close(): void;\n}', - }, - { - name: 'TuiOverlayMargin', - declaration: 'export interface TuiOverlayMargin {\n readonly top?: number;\n readonly right?: number;\n readonly bottom?: number;\n readonly left?: number;\n}', - }, - { - name: 'TuiOverlayOptions', - declaration: 'export interface TuiOverlayOptions {\n readonly width?: number | `${number}%`;\n readonly minWidth?: number;\n readonly maxHeight?: number | `${number}%`;\n readonly anchor?: TuiOverlayAnchor;\n readonly margin?: number | TuiOverlayMargin;\n}', - }, - { - name: 'TuiOverlayOutcome', - declaration: 'export type TuiOverlayOutcome = {\n readonly reason: Exclude;\n} | {\n readonly reason: \'error\';\n readonly error: unknown;\n};', - }, - { - name: 'TuiOverlayRequest', - declaration: 'export interface TuiOverlayRequest {\n readonly create: (host: TuiOverlayHost) => TuiComponent & Partial;\n readonly options?: TuiOverlayOptions;\n readonly signal?: AbortSignal;\n}', - }, - { - name: 'TuiOverlaySession', - declaration: 'export interface TuiOverlaySession {\n readonly state: TuiOverlayState;\n readonly closed: Promise;\n close(): Promise;\n}', - }, - { - name: 'TuiOverlayState', - declaration: 'export type TuiOverlayState = \'queued\' | \'active\' | \'closed\';', - }, - { - name: 'TuiTheme', - declaration: 'export interface TuiTheme {\n readonly text: (value: string) => string;\n readonly muted: (value: string) => string;\n readonly dim: (value: string) => string;\n readonly accent: (value: string) => string;\n readonly success: (value: string) => string;\n readonly warning: (value: string) => string;\n readonly error: (value: string) => string;\n readonly bold: (value: string) => string;\n}', - }, - { - name: 'TuiViewport', - declaration: 'export interface TuiViewport {\n readonly columns: number;\n readonly rows: number;\n}', - }, { name: 'TurnEndReason', declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];', diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 8a88859ab3..c60ba94b3c 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -131,6 +131,7 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) ctx.plugin(UserInteractionService) + ctx.plugin(uiTui.TuiPromptService) ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index cdab42289b..e5c8b778be 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -40,7 +40,7 @@ describe('dsh-tui-demo app', () => { }, welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - ui: { color: false, maxToolOutputLines: 3 }, + ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, @@ -55,6 +55,7 @@ describe('dsh-tui-demo app', () => { 'SessionQuerySqlite', 'SessionReferenceService', 'UserInteractionService', + 'TuiPromptService', 'ui-tui', 'agent-spine-demo', 'tool-ask-user', @@ -67,15 +68,15 @@ describe('dsh-tui-demo app', () => { candidateLimit: 7, maxReferenceBytes: 1234, }) - const tuiConfig = calls[7]?.config as { sessionId: string } + const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', resumeCommand: 'dsh --resume {session}', - color: false, + theme: { color: false }, maxToolOutputLines: 3, }) expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[8]?.config as { + const spineConfig = calls[9]?.config as { readonly agents: Array> readonly goals: Record readonly maxParallelToolCalls: number @@ -111,8 +112,8 @@ describe('dsh-tui-demo app', () => { expect(calls[2]?.config).toEqual({ root: './.sessions' }) expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[7]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[8]?.config as { agents: Array> }).agents[0]).toMatchObject({ + expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ id: 'main', resumeSessionId: 'persisted-session', }) @@ -128,12 +129,12 @@ describe('dsh-tui-demo app', () => { workspaceContext: false, }) - const tuiConfig = calls[6]?.config as { sessionId: string } + const tuiConfig = calls[7]?.config as { sessionId: string } expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[7]?.config as { agents: Array> }).agents[0]) + expect((calls[8]?.config as { agents: Array> }).agents[0]) .toMatchObject({ sessionId: tuiConfig.sessionId }) expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[7]?.config).toMatchObject({ goals: false }) + expect(calls[8]?.config).toMatchObject({ goals: false }) }) it('has the namespace-plugin export shape so the Loader keeps its schema', () => { diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index ed4050bf11..88c07a0853 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -18,6 +18,7 @@ import { } from '../feature.ts' import { ProjectContribution, type ProjectResource } from '../resources.ts' import { + cordisConfigEntry, npmCordisConfigEntry, optionalString, ownedTextFile, @@ -86,6 +87,10 @@ class AppOption extends FeatureOption { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', }), + cordisConfigEntry(ID, { + id: 'tui-prompt', + name: '@deepseek-ai/dsh-tui/prompt', + }), ...npmCordisConfigEntry(ID, { id: 'tui', name: '@deepseek-ai/dsh-tui', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index f1216d2768..3a8c3b3158 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -187,6 +187,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model') + expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant') expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant') diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a11958da85..7bf2eead46 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/tui/README.md -README.md: 84ed3de78469ab778118ee5599acfd8476b0ecd1 -README.zh.md: 771b89a91077db7543713b4ce1b5fce0c30c2a16 +README.md: 528daef773635451ceb198ab3231c2dd87cb9413 +README.zh.md: ed19023334389e3f64b8a2f3821307f1540876f2 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 84ed3de784..528daef773 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -74,7 +74,7 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 771b89a910..ed19023334 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -74,7 +74,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 ## 颜色 -Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、工具卡片)使用彩色左侧 gutter bar,而非填充背景块;问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 3242f5a2a3..f9260e3231 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./prompt": { + "types": "./lib/types/prompt.d.ts", + "default": "./lib/prompt.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/prompt.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -35,9 +40,9 @@ "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -59,6 +64,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "saxes": "6.0.0", "schemastery": "^3.18.0" }, "devDependencies": { @@ -71,9 +77,9 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/autocomplete.ts b/packages/ui/tui/src/autocomplete.ts new file mode 100644 index 0000000000..d8a639ad2f --- /dev/null +++ b/packages/ui/tui/src/autocomplete.ts @@ -0,0 +1,95 @@ +/** + * Editor autocomplete provider merging path-only file candidates and optional + * session-reference snapshots with the base slash-command completions. + * @module @deepseek-ai/dsh-tui/autocomplete + */ + +import { + CombinedAutocompleteProvider, + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { + formatSessionReferenceMention, + type SessionReferenceService, +} from '@deepseek-ai/dsh-session-reference' +import { displayInlineText } from './components/text.ts' +import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts' + +/** Merge path-only file candidates and optional session snapshots with commands. */ +export class ReferenceAutocompleteProvider implements AutocompleteProvider { + constructor( + private readonly base: CombinedAutocompleteProvider, + private readonly files: WorkspaceFileSearch, + private readonly sessions: SessionReferenceService | undefined, + private readonly agent: Agent, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) + const currentLine = lines[cursorLine] + /* v8 ignore next -- Editor always supplies its current state line. */ + if (currentLine === undefined) return basePromise + const token = activeAtToken(currentLine, cursorCol) + if (token === undefined) { + this.files.invalidate() + return basePromise + } + const filePromise = this.files.list(token.query, options.signal).catch(() => []) + const sessionPromise = this.sessions === undefined || token.quoted + ? Promise.resolve([]) + : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) + const [base, fileCandidates, sessionCandidates] = await Promise.all([ + basePromise, + filePromise, + sessionPromise, + ]) + if (options.signal.aborted) return base + const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { + const value = formatFileMention(candidate, token.quoted) + if (value === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + return [{ + value, + label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, + description: displayInlineText(candidate.path), + }] + }) + const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { + const mentionLabel = displayInlineText(candidate.label) + const sessionId = displayInlineText(candidate.sessionId) + const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) + const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + return { + value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), + label: `Session · ${mentionLabel}`, + description, + } + }) + const items = [...fileItems, ...sessionItems] + if (items.length === 0) return base + return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) + } +} diff --git a/packages/ui/tui/src/components/content.ts b/packages/ui/tui/src/components/content.ts new file mode 100644 index 0000000000..a4536a6afd --- /dev/null +++ b/packages/ui/tui/src/components/content.ts @@ -0,0 +1,56 @@ +/** + * Content-block primitives shared across the terminal front door: flattening + * session content to display text and parsing tool-call arguments. + * @module @deepseek-ai/dsh-tui/components/content + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** + * Flatten content blocks into a single display string, recursing into + * tool-result content and naming unknown block types. + * @param content - Content blocks to flatten. + * @returns The concatenated display text. + */ +export function contentText(content: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + case 'reasoning': + parts.push(block.text) + break + case 'tool-call': + parts.push(`${block.name}(${block.arguments})`) + break + case 'tool-result': + parts.push(contentText(block.content)) + break + default: { + const rawType = (block as { type?: unknown }).type + parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) + break + } + } + } + return parts.join('') +} + +/** A tool call's arguments parsed from their JSON source, with a validity flag. */ +export interface ParsedArguments { + value: unknown + valid: boolean +} + +/** + * Parse tool-call arguments from their JSON source. + * @param raw - Raw JSON arguments text. + * @returns The parsed value, or the raw text with `valid: false` on parse failure. + */ +export function parseArguments(raw: string): ParsedArguments { + try { + return { value: JSON.parse(raw), valid: true } + } catch { + return { value: raw, valid: false } + } +} diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts new file mode 100644 index 0000000000..5ac4816482 --- /dev/null +++ b/packages/ui/tui/src/components/dialogs.ts @@ -0,0 +1,790 @@ +/** + * pi-tui dialog and selector components for the terminal front door: the status + * card, prompt-context line, model selector, resume picker, and user-question + * dialog, plus the model-choice and resume-candidate data they present. + * @module @deepseek-ai/dsh-tui/components/dialogs + */ + +import { + Input, + Key, + SelectList, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, + type Focusable, + type SelectItem, +} from '@earendil-works/pi-tui' +import type { Context } from 'cordis' +import { + type Agent, + type AgentLlmTarget, +} from '@deepseek-ai/dsh-agent' +import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' +import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' +import { dialogSelectTheme, type Palette } from './theme.ts' +import { + renderTuiPromptTemplate, + type TuiPromptTemplateToken, +} from '../prompt.ts' + +/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */ +export interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string + reasoning?: LlmModelReasoningInfo +} + +/** + * The provider/model route and selected reasoning effort resolved from a model dialog. + */ +export interface ModelDialogSelection { + choice: ModelChoice + reasoningEffort: ReasoningEffortId | undefined +} + +/** + * Format a provider/model target as its `provider/model` label. + * @param target - The LLM target. + * @returns The `provider/model` label. + */ +export function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +/** + * Format a target compactly as its model name with any selected reasoning effort appended. + * @param target - The LLM target. + * @returns The compact `model [effort]` label. + */ +export function compactTargetLabel(target: AgentLlmTarget): string { + return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` +} + +/** + * Resolve the display label for a choice's reasoning effort. + * @param choice - The model choice carrying advertised reasoning metadata. + * @param effort - The selected effort, or `undefined` for provider default. + * @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata. + */ +export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort +} + +/** + * Derive the agent's initial LLM target from its logged request header or options. + * @param agent - The driven agent. + * @returns The initial target, or `undefined` when unset. + */ +export function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) { + if (logged.reasoningEffort === undefined) { + return { provider: logged.provider, model: logged.model } + } + return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort } + } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +/** + * List every advertised model across registered providers, appending the current + * target when a provider does not advertise it. + * @param ctx - Context supplying the LLM service. + * @param current - The current target, appended when unadvertised. + * @returns The model choices, flattened across providers. + */ +export async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return Promise.all(models.map(async (model): Promise => { + const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning + return { + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...reasoning === undefined ? {} : { reasoning }, + } + })) + })) + return groups.flat() +} + +/** + * Format a diagnostic integer with grouping separators. + * @param value - Integer to format. + * @returns The grouped decimal string. + */ +export function formatDiagnosticNumber(value: number): string { + return value.toLocaleString('en-US') +} + +/** + * Format a diagnostic timestamp as an ISO date-time in UTC. + * @param value - Epoch milliseconds. + * @returns The formatted UTC timestamp. + */ +export function formatDiagnosticTime(value: number): string { + return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') +} + +/** + * Format a pluralized count for a diagnostic row. + * @param value - Count. + * @param singular - Singular noun; an `s` is appended for other counts. + * @returns The formatted count. + */ +export function formatDiagnosticCount(value: number, singular: string): string { + return `${String(value)} ${singular}${value === 1 ? '' : 's'}` +} + +/** + * Render a fixed-width filled meter bar for a percentage. + * @param percent - Percentage in [0, 100]. + * @param palette - Active role palette. + * @returns The rendered meter. + */ +export function diagnosticMeter(percent: number, palette: Palette): string { + const width = 16 + const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) + return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` +} + +/** One `label: value` row of a status card group. */ +export type StatusCardRow = readonly [label: string, value: string] + +/** Bordered, grouped field card for one point-in-time status snapshot. */ +export class StatusCardComponent implements Component { + constructor( + private readonly groups: readonly (readonly StatusCardRow[])[], + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) + const naturalLabelWidth = Math.max(...labels.map(label => label.length)) + const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => + 1 + naturalLabelWidth + 2 + visibleWidth(value)))) + const cardWidth = Math.min( + Math.max(8, width), + Math.max('Session status'.length + 5, naturalBodyWidth + 4), + ) + const innerWidth = Math.max(1, cardWidth - 4) + const labelWidth = Math.min( + naturalLabelWidth, + Math.max(1, Math.floor(innerWidth / 3)), + ) + const body: string[] = [] + for (const [groupIndex, group] of this.groups.entries()) { + if (groupIndex > 0) body.push('') + for (const [label, value] of group) { + const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') + const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const continuation = ' '.repeat(1 + labelWidth + 2) + const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) + const wrapped = wrapTextWithAnsi(value, valueWidth) + for (const [lineIndex, line] of wrapped.entries()) { + body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) + } + } + } + + const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') + const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) + const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` + const lines = [top] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) + } + lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) + return lines + } +} + +/** The left/right template line rendered above the editor. */ +export class PromptContextComponent implements Component { + constructor( + private readonly leftTemplate: readonly TuiPromptTemplateToken[], + private readonly rightTemplate: readonly TuiPromptTemplateToken[], + private readonly resolve: (name: string) => string | undefined, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '') + const rightWidth = visibleWidth(right) + const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2)) + const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '') + if (rightWidth === 0) return [left] + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth)) + return [`${left}${gap}${right}`] + } +} + +/** A user's answer to one question: chosen option labels and an optional custom answer. */ +export interface QuestionSelection { + selected: string[] + custom?: string +} + +/** + * Render a bordered dialog frame around body lines with a titled top edge. + * @param title - Dialog title shown in the top border. + * @param body - Body lines. + * @param width - Dialog width in columns. + * @param palette - Active role palette. + * @returns The framed dialog lines. + */ +export function renderDialog( + title: string, + body: readonly string[], + width: number, + palette: Palette, +): string[] { + const innerWidth = Math.max(1, width - 4) + const topLabel = ` ${displayText(title)} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [palette.accent(top)] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) + } + lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines +} + +/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */ +export class ModelDialog implements Component { + private readonly list: SelectList + private readonly items: Map + private readonly choices: Map + private readonly efforts: Map + private readonly currentValue: string | undefined + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (selection: ModelDialogSelection) => void, + cancel: () => void, + ) { + this.items = new Map() + this.choices = new Map() + this.efforts = new Map() + this.currentValue = current === undefined ? undefined : targetLabel(current) + for (const choice of choices) { + const value = targetLabel(choice) + const isCurrent = current?.provider === choice.provider && current.model === choice.model + this.choices.set(value, choice) + this.efforts.set( + value, + isCurrent + ? current.reasoningEffort ?? choice.reasoning?.defaultEffort + : choice.reasoning?.defaultEffort, + ) + this.items.set(value, { + value, + label: displayText(value), + description: this.describeChoice(choice, isCurrent), + }) + } + this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) + const currentIndex = current === undefined + ? 0 + : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) + this.list.setSelectedIndex(currentIndex) + this.list.onSelect = (item) => { + const selected = choices.find(choice => targetLabel(choice) === item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) + } + this.list.onCancel = cancel + } + + private describeChoice(choice: ModelChoice, isCurrent: boolean): string { + const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice))) + return [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...effortLabel === undefined ? [] : [displayText(effortLabel)], + ...isCurrent ? ['current'] : [], + ].join(' — ') + } + + private cycleReasoningEffort(): void { + const selectedItem = this.list.getSelectedItem() + /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ + if (selectedItem === null) return + const choice = this.choices.get(selectedItem.value) + if (choice?.reasoning === undefined) return + const current = this.efforts.get(selectedItem.value) + const efforts: Array = [ + ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], + ...choice.reasoning.efforts.map(effort => effort.id), + ] + const currentIndex = efforts.indexOf(current) + const next = efforts[(currentIndex + 1) % efforts.length] + this.efforts.set(selectedItem.value, next) + const item = this.items.get(selectedItem.value) + /* v8 ignore next -- items and choices are constructed from the same values. */ + if (item === undefined) return + item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.shift(Key.tab))) { + this.cycleReasoningEffort() + } else { + this.list.handleInput(data) + } + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Select model', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), + ], width, this.palette) + } +} + +/** The provider/model route recovered from a resume candidate's log. */ +export interface ResumeRoute { + provider: string + model: string +} + +/** A preflighted resume selector row summarizing one persisted session. */ +export interface ResumeCandidate { + record: SessionRecord + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'rejected': return `turn ${event.data.turn}: rejected` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +/** + * Build one resume selector row from a record and its log snapshot, deriving the + * title, route, goal phase, and any reason the session cannot be resumed here. + * @param record - The session record. + * @param snapshot - The session's log snapshot. + * @param currentId - The current session id. + * @param cwd - The current workspace directory. + * @param availableProviders - Providers registered in this runtime. + * @returns The summarized resume candidate. + */ +export function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + availableProviders: ReadonlySet, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + const foldedGoal = foldGoal(snapshot.events).goal + let disabledReason: string | undefined + if (record.header.id === currentId) disabledReason = 'current session' + else if (record.live) disabledReason = 'session is already live in this runtime' + else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (route !== undefined && !availableProviders.has(route.provider)) { + disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` + } + return { + record, + title, + lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + lastTurn: resumeTurnLabel(snapshot), + ...route === undefined ? {} : { route }, + /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +export class ResumePicker implements Component, Focusable { + private readonly search = new Input() + private pasteBuffer: string | undefined + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void { + this.search.invalidate() + } + + private filtered(): ResumeCandidate[] { + const query = this.search.getValue().trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return + const filtered = this.filtered() + if (matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.visibleCandidateCount(), + ) + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + } + this.invalidate() + } + + render(width: number): string[] { + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, + '', + ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const visibleCount = this.visibleCandidateCount() + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const active = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) + } +} + +/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +export class QuestionDialog implements Component, Focusable { + private selectedIndex = 0 + private selected = new Set() + private mode: 'options' | 'custom' + private error = '' + private readonly input = new Input() + private readonly options: NonNullable + focused = false + + constructor( + private readonly question: AskUserQuestionItem, + private readonly position: number, + private readonly total: number, + private readonly unanswered: number, + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (selection: QuestionSelection) => void, + private readonly cancel: () => void, + ) { + this.options = question.options ?? [] + this.mode = this.options.length > 0 ? 'options' : 'custom' + this.input.onSubmit = (value) => { this.submitCustom(value) } + this.input.onEscape = () => { + if (this.options.length > 0) { + this.mode = 'options' + this.error = '' + } else { + this.cancel() + } + } + } + + invalidate(): void { + this.input.invalidate() + } + + handleInput(data: string): void { + this.invalidate() + if (this.mode === 'custom') { + this.input.focused = this.focused + this.input.handleInput(data) + return + } + const options = this.options + if (matchesKey(data, Key.up)) { + this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 + } else if (matchesKey(data, Key.space) && this.question.multiSelect) { + if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) + else this.selected.add(this.selectedIndex) + } else if (matchesKey(data, Key.enter)) { + const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] + if (indices.length === 0) { + this.error = 'Select at least one option, or press Tab for a custom answer.' + return + } + this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { + this.mode = 'custom' + this.error = '' + } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + } + } + + private submitCustom(value: string): void { + const custom = value.trim() + if (custom === '') { + this.error = 'Enter an answer before submitting.' + return + } + this.done({ selected: [], custom }) + } + + render(width: number): string[] { + this.input.focused = this.focused + const innerWidth = Math.max(1, width - 4) + const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` + const lines = [ + this.palette.muted(header), + ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + ] + const push = (line: string): void => { lines.push(line) } + // Supporting detail (e.g. the full plan under review) renders between the + // question and the answer surface, kept out of option labels. + if (this.question.detail !== undefined) { + push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) + } + push('') + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) push(line) + push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) + } else { + const options = this.options + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + options.length - this.maxVisible, + )) + const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) + for (let index = start; index < end; index += 1) { + // `index < end <= options.length`; the options array is borrowed immutably for this dialog. + const option = options[index] as NonNullable[number] + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + const leftStyled = index === this.selectedIndex + ? this.palette.bold(this.palette.accent(left)) + : left + const description = option.description === undefined + ? '' + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + push(`${leftStyled}${description}`) + } + if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + const controls = [ + 'Tab custom answer', + ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter submit', + 'Esc interrupt', + ] + const hint = this.palette.dim(controls.join(' • ')) + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + } + if (this.error) { + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + } + return ['', ...lines, ''].map((line) => { + const clipped = truncateToWidth(line, innerWidth, '') + return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + }) + } +} diff --git a/packages/ui/tui/src/components/text.ts b/packages/ui/tui/src/components/text.ts new file mode 100644 index 0000000000..876893adfd --- /dev/null +++ b/packages/ui/tui/src/components/text.ts @@ -0,0 +1,49 @@ +/** + * Terminal text sanitization shared across the pi-tui front door. External text + * (model output, tool results, clipboard) is escaped or stripped of C0/C1 + * controls before the TUI adds its own application-owned ANSI. + * @module @deepseek-ai/dsh-tui/components/text + */ + +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu + +/** Bracketed-paste start marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_START = '\u001B[200~' +/** Bracketed-paste end marker emitted by terminals around pasted content. */ +export const BRACKETED_PASTE_END = '\u001B[201~' + +/** + * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. + * Line feeds remain structural so transcript and tool output retain their layout. + * @param text - Untrusted text to render. + * @returns The text with control characters escaped as `\xNN`. + */ +export function displayText(text: string): string { + return text.replace(TERMINAL_CONTROL_PATTERN, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) +} + +/** + * Escape external controls for terminal fields that must remain on one line. + * @param text - Untrusted text to render inline. + * @returns The escaped text with newlines rendered as `\x0a`. + */ +export function displayInlineText(text: string): string { + return displayText(text).replaceAll('\n', '\\x0a') +} + +/** + * Remove terminal controls from clipboard text before an editable field stores it. + * @param text - Raw pasted clipboard text. + * @returns The text stripped of OSC, CSI, escape, and control sequences. + */ +export function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts new file mode 100644 index 0000000000..1ea75b437f --- /dev/null +++ b/packages/ui/tui/src/components/theme.ts @@ -0,0 +1,184 @@ +/** + * Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front + * door. The palette is built from the standard 16-color ANSI set plus SGR + * attributes so every terminal remaps it to its active color scheme. + * @module @deepseek-ai/dsh-tui/components/theme + */ + +import type { + MarkdownTheme, + SelectListTheme, + TerminalColorScheme, +} from '@earendil-works/pi-tui' + +/** Theme-agnostic role colors and SGR attribute wrappers. */ +export interface Palette { + accent: (text: string) => string + accent2: (text: string) => string + text: (text: string) => string + muted: (text: string) => string + dim: (text: string) => string + success: (text: string) => string + warning: (text: string) => string + error: (text: string) => string + code: (text: string) => string + added: (text: string) => string + removed: (text: string) => string + bold: (text: string) => string + italic: (text: string) => string + underline: (text: string) => string + strike: (text: string) => string + /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ + selected: (text: string) => string +} + +function ansi(open: string, close: string, enabled: boolean): (text: string) => string { + return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +} + +/** + * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR + * attributes, which every terminal remaps to its active color scheme. Body + * `text` stays the terminal's default foreground so it reads on light and dark + * backgrounds alike; grouping uses foreground-only bold, underlined role + * headers and reverse video rather than fixed background fills or per-line + * prefixes, so a transcript drag-select copies message text without stray + * glyphs. + * + * @param enabled - Whether ANSI is emitted at all. + * @param scheme - Active terminal color scheme; adjusts dim and code roles. + * @returns The role palette for the given scheme. + */ +export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { + return { + accent: ansi('94', '39', enabled), + accent2: ansi('95', '39', enabled), + text: text => text, + muted: ansi('90', '39', enabled), + // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 + // (bright black / gray) which renders as a readable muted tone on any scheme. + dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), + success: ansi('32', '39', enabled), + warning: ansi('33', '39', enabled), + error: ansi('31', '39', enabled), + // ANSI 36 (cyan) is difficult to read on a light background — use + // ANSI 34 (blue) which is legible on both light and dark schemes. + code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), + added: ansi('32', '39', enabled), + removed: ansi('31', '39', enabled), + bold: ansi('1', '22', enabled), + italic: ansi('3', '23', enabled), + underline: ansi('4', '24', enabled), + strike: ansi('9', '29', enabled), + selected: ansi('7', '27', enabled), + } +} + +/** + * DeepSeek brand gradient stops (indigo → light blue) taken from the + * deepseek.com logo, painted across the startup banner's product name on + * truecolor terminals. Fixed brand identity, deliberately outside the + * theme-adaptive {@link Palette}. + */ +const BRAND_GRADIENT = [ + [77, 107, 254], // #4D6BFE + [57, 130, 255], // #3982FF + [36, 152, 255], // #2498FF +] as const + +/** + * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear + * interpolation across its stops. + * + * @param t - Position along the gradient; clamped to [0, 1]. + * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. + */ +function brandColorAt(t: number): readonly [number, number, number] { + const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) + const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) + const local = span - index + // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. + const from = BRAND_GRADIENT[index] as readonly [number, number, number] + const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] + return [ + Math.round(from[0] + (to[0] - from[0]) * local), + Math.round(from[1] + (to[1] - from[1]) * local), + Math.round(from[2] + (to[2] - from[2]) * local), + ] +} + +/** + * Paint `text` left-to-right in the DeepSeek brand gradient with per-character + * 24-bit foreground codes, resetting to the default foreground at the end. + * Foreground-only, so it stays legible on any terminal background; the caller + * gates it on truecolor support and wraps it in bold. + * + * @param text - Text to colorize; sampled once per character. + * @returns `text` wrapped in truecolor SGR foreground codes. + */ +export function gradientText(text: string): string { + // The sole caller passes the ASCII product name, so UTF-16 unit iteration + // samples exactly one color per visible letter. + const last = Math.max(1, text.length - 1) + let painted = '' + for (let index = 0; index < text.length; index += 1) { + const [r, g, b] = brandColorAt(index / last) + painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` + } + return `${painted}\x1b[39m` +} + +/** + * Derive the pi-tui Markdown theme from a role palette. + * @param palette - Active role palette. + * @returns The Markdown theme wired to palette roles. + */ +export function markdownTheme(palette: Palette): MarkdownTheme { + return { + heading: text => palette.accent(text), + link: text => palette.accent(text), + // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. + /* v8 ignore next */ + linkUrl: text => palette.dim(text), + code: text => palette.code(text), + codeBlock: text => palette.code(text), + // pi-tui presents both fence rows through this callback. Keep the opening + // language label, but hide Markdown syntax and the otherwise-empty close. + codeBlockBorder: text => palette.dim(text.slice(3)), + quote: text => palette.muted(text), + quoteBorder: text => palette.accent2(text), + hr: text => palette.dim(text), + listBullet: text => palette.accent(text), + bold: text => palette.bold(text), + italic: text => palette.italic(text), + strikethrough: text => palette.strike(text), + underline: text => palette.underline(text), + } +} + +/** + * Derive the pi-tui select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The select-list theme wired to palette roles. + */ +export function selectTheme(palette: Palette): SelectListTheme { + return { + selectedPrefix: palette.accent, + selectedText: palette.accent, + description: palette.muted, + scrollInfo: palette.dim, + noMatch: palette.warning, + } +} + +/** + * Derive the reverse-video dialog select-list theme from a role palette. + * @param palette - Active role palette. + * @returns The dialog select-list theme with a reverse-video selection. + */ +export function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts new file mode 100644 index 0000000000..41835ee31f --- /dev/null +++ b/packages/ui/tui/src/components/transcript.ts @@ -0,0 +1,529 @@ +/** + * pi-tui transcript components: the startup banner, user/assistant messages, + * per-step timing footer, streaming assistant buffer, tool cards, and the todo + * panel. Each is a pure function of its inputs and the active palette. + * @module @deepseek-ai/dsh-tui/components/transcript + */ + +import { + Container, + Markdown, + Spacer, + Text, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type MarkdownTheme, +} from '@earendil-works/pi-tui' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' +import type { + TerminalCallView, + ToolCallView, + ToolDefinition, + ToolResultView, +} from '@deepseek-ai/dsh-tools' +import type { FileDiff } from '@deepseek-ai/dsh-tools' +import { renderUnknownXml } from '../xml-tool-output.ts' +import { displayInlineText, displayText } from './text.ts' +import { gradientText, type Palette } from './theme.ts' +import { contentText, type ParsedArguments } from './content.ts' +import { + formatCompletionTime, + formatTimingTotals, + stepTimingAt, + type StepPosition, +} from '../session/timing.ts' + +/** Concatenate the text of every block of one type, separated by blank lines. */ +function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { + return content + .filter((block): block is Extract => block.type === type) + .map(block => block.text) + .join('\n\n') +} + +/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */ +function pretty(value: unknown): string { + if (typeof value === 'string') return displayText(value) + // JSON.stringify is typed to return string but yields undefined for e.g. symbols. + const serialized = JSON.stringify(value, null, 2) as string | undefined + return displayText(serialized ?? String(value)) +} + +/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ +function diffLines(diff: FileDiff, palette: Palette): string[] { + // The card header is a fixed `Tool / ` frame that never names a file, so + // each hunk always carries its own path header (no redundancy to suppress). + const lines = [palette.bold(displayText(diff.path))] + if (diff.oldText !== null) { + for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) + } + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) + return lines +} + +/** + * A message's bold, underlined role header in the role color. The underline + * bands each role without a background fill or per-line prefix, so it reads on + * any theme and a body drag-select copies the message text verbatim. + */ +function messageHeader(label: string, color: (text: string) => string, palette: Palette): string { + return palette.bold(palette.underline(color(displayText(label)))) +} + +/** + * Borderless startup banner: product title, an optional configured subtitle, + * and the session id. No box frame — each line renders as plain left-padded + * text (matching transcript notices) so it reads on any theme. + */ +export class HeaderComponent implements Component { + /** Columns of the banner currently revealed; `undefined` renders it whole. */ + private revealWidth: number | undefined + + constructor( + private readonly agent: Agent, + private readonly subtitle: () => string | undefined, + private readonly palette: Palette, + private readonly gradient: boolean, + ) {} + + /** + * Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. + * @param width - Revealed banner width in columns, or `undefined` for the whole banner. + */ + setRevealWidth(width: number | undefined): void { + this.revealWidth = width + } + + invalidate(): void {} + + render(width: number): string[] { + const usable = Math.max(1, width - 2) + const name = this.gradient + ? this.palette.bold(gradientText('DEEPSEEK')) + : this.palette.bold(this.palette.accent('DEEPSEEK')) + const title = `${name} ${this.palette.bold('HARNESS')}` + const detail = displayText(this.agent.session.id) + const subtitle = this.subtitle() + const lines = [ + title, + ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], + this.palette.dim(detail), + ] + .flatMap(line => wrapTextWithAnsi(line, usable)) + .map(line => ` ${truncateToWidth(line, usable, '')}`) + if (this.revealWidth === undefined) return lines + const revealed = this.revealWidth + return lines.map(line => truncateToWidth(line, revealed, '')) + } +} + +/** + * A user or steering prompt in the transcript. An underlined accent role header + * plus blank-line spacing separate it from surrounding blocks; body lines carry + * no prefix or indent, so a terminal drag-select copies the prompt verbatim. + */ +export class UserMessageComponent extends Container { + constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { + super() + this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0)) + this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + })) + } +} + +/** Children of a settled assistant message: optional reasoning block then the response text. */ +function assistantMessageChildren( + content: readonly ContentBlock[], + showReasoning: boolean, + palette: Palette, + mdTheme: MarkdownTheme, +): Component[] { + const reasoning = displayText(textBlocks(content, 'reasoning').trim()) + const text = displayText(textBlocks(content, 'text').trim()) + const children: Component[] = [ + new Spacer(1), + new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0), + ] + if (reasoning && showReasoning) { + children.push( + new Text(palette.italic(palette.muted('Reasoning')), 0, 0), + new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }), + ) + } + if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) + return children +} + +/** + * A step's timing summary, rendered as a self-refreshing footer that stays at + * the tail of the step's output. Kept separate from the assistant message so + * the timing line trails any tool cards the step appends after its message. + */ +class StepTimingComponent extends Container { + private completionTime: number | undefined + + constructor( + private readonly position: StepPosition, + private readonly events: () => readonly SessionEvent[], + private readonly now: () => number, + private readonly palette: Palette, + ) { + super() + this.rebuild() + } + + complete(time: number): void { + this.completionTime = time + this.rebuild() + } + + override invalidate(): void { + this.rebuild() + super.invalidate() + } + + private rebuild(): void { + this.clear() + const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now()) + const timing = formatTimingTotals(totals, true) + const header = this.completionTime === undefined + ? timing + : `${timing} · Completed ${formatCompletionTime(this.completionTime)}` + this.addChild(new Text(this.palette.dim(header), 0, 0)) + } +} + +interface StreamingBlock { + type: string + text: string +} + +/** A live assistant step: streamed reasoning/text blocks until the message settles. */ +export class StreamingAssistantComponent extends Container { + private readonly blocks = new Map() + private settledContent: readonly ContentBlock[] | undefined + /** + * The step's timing footer. The renderer keeps it at the tail of the chat so + * it trails any tool cards the step appends after this assistant message; it + * is not a child of this component. + */ + readonly timing: StepTimingComponent + + constructor( + position: StepPosition, + events: () => readonly SessionEvent[], + now: () => number, + private showReasoning: boolean, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + super() + this.timing = new StepTimingComponent(position, events, now, palette) + this.rebuild() + } + + /** + * Replace the streamed blocks with the step's settled content. + * @param content - The settled assistant content blocks. + */ + settle(content: readonly ContentBlock[]): void { + this.settledContent = content + this.rebuild() + } + + /** + * Whether this step's assistant message has settled. + * @returns `true` once {@link settle} has run. + */ + isSettled(): boolean { + return this.settledContent !== undefined + } + + /** + * Pin the step's timing footer to its completion time. + * @param time - Step completion time in epoch milliseconds. + */ + complete(time: number): void { + this.timing.complete(time) + } + + override invalidate(): void { + this.rebuild() + this.timing.invalidate() + super.invalidate() + } + + /** + * Fold one streamed chunk into the live block buffer and re-render. + * @param chunk - The streamed assistant chunk. + */ + update(chunk: StreamChunk): void { + if (chunk.type === 'block-start') { + this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) + } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { + const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' + const block = this.blocks.get(chunk.index) ?? { type, text: '' } + block.text += chunk.text + this.blocks.set(chunk.index, block) + } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { + this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } + this.rebuild() + this.timing.invalidate() + } + + /** + * Toggle whether reasoning blocks render, then re-render. + * @param show - Whether to show reasoning blocks. + */ + setShowReasoning(show: boolean): void { + this.showReasoning = show + this.rebuild() + } + + private rebuild(): void { + this.clear() + const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + .sort(([left], [right]) => left - right) + .flatMap(([, block]) => { + if (block.type === 'text') return [{ type: 'text', text: block.text }] + if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + return [] + }) + for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { + this.addChild(child) + } + } +} + +/** A tool call and its result, rendered as a collapsible status card. */ +export class ToolCardComponent implements Component { + private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined + private expanded = false + private callView: ToolCallView + private resultView: ToolResultView | undefined + + constructor( + private readonly name: string, + private readonly parsed: ParsedArguments, + private readonly definition: ToolDefinition | undefined, + private readonly maxOutputLines: number, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + this.callView = this.presentCall() + } + + private presentCall(): ToolCallView { + if (this.parsed.valid && this.definition?.presentCall) { + try { + const view = this.definition.presentCall(this.parsed.value) + if (view !== undefined) return view + } catch (error: unknown) { + return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } + } + } + return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } + } + + /** + * Record the tool result and derive its result view. + * @param event - The `tool/result` event payload. + */ + updateResult(event: Extract['data']): void { + this.result = { + content: [...event.content], + isError: event.isError, + ...event.meta !== undefined ? { meta: event.meta } : {}, + } + if (this.parsed.valid && this.definition?.presentResult) { + try { + const view = this.definition.presentResult(this.parsed.value, this.result) + if (view !== undefined) this.resultView = view + } catch (error: unknown) { + this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } + } + } + } + + /** + * Expand or collapse the card's body preview. + * @param expanded - Whether the full body is shown. + */ + setExpanded(expanded: boolean): void { + this.expanded = expanded + } + + invalidate(): void {} + + render(width: number): string[] { + const isError = this.result?.isError ?? false + // A ring marker: hollow while the call is pending, filled once it settles; + // the header color (warning/success/error) tells pending from ok from error. + const glyph = this.result === undefined ? '○' : '●' + const rawBody = this.renderBody() + const view = this.resultView ?? this.callView + const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined + const unknownXml = this.definition === undefined && genericContent !== undefined + ? renderUnknownXml( + displayText(contentText(genericContent)), + this.maxOutputLines, + this.expanded, + displayText, + text => this.palette.muted(text), + /* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */ + count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`), + ) + : undefined + const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0 + ? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width) + : rawBody) + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines + const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines + ? body + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] + // The header is a fixed `Tool / ` frame in the status color (warning + // pending / success ok / error), flat — no bold or underline, so one color + // reads consistently across the whole row. Every tool-specific detail (a + // read's path, a diff, command output) lives in the body below; the sole + // header extra is a bash card's model-authored description, appended as a + // `/ ` segment. The body stays unprefixed so a drag-select copies only + // the tool text; body lines pass through Text so overlong output wraps. + const statusColor = this.result === undefined + ? this.palette.warning + : isError ? this.palette.error : this.palette.success + // The header is a single card row: collapse an embedded newline in the + // description to an inline escape so it cannot break onto extra rows and + // collide with the body lines that follow. + const desc = this.headerDescription() + const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}` + const header = truncateToWidth(headerText, Math.max(1, width - 2), '') + const lines = [statusColor(header)] + if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) + return lines + } + + /** The pending terminal call view, when this row is a terminal card. */ + private terminalPending(): TerminalCallView | undefined { + return this.callView.card === 'terminal' ? this.callView : undefined + } + + /** + * The optional header `/ ` segment: a bash (terminal) card's + * model-authored description. Non-terminal tools contribute no header detail — + * their presenter title moves into the body instead. + */ + private headerDescription(): string | undefined { + const description = this.terminalPending()?.description + return description !== undefined && description !== '' ? description : undefined + } + + /** + * The presenter's title for a non-terminal card, shown as the first body line + * (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a + * fixed `Tool / ` frame. The result-state title replaces the pending one. + */ + private bodyTitle(): string { + return this.resultView?.title ?? this.callView.title + } + + private renderBody(): string[] { + const view = this.resultView ?? this.callView + if (view.card === 'terminal') { + const pending = this.terminalPending() + const lines: string[] = [] + // The command shows as a $-line here whenever it is not the header: either a + // description headlines the row (the command still belongs somewhere) or the row + // is a pending undescribed call (the classic running-command echo). A completed + // undescribed row keeps the command only in the header. + // The command and cwd are each a single card row, so escape a multi-line + // command inline (displayInlineText) — a real newline would break onto extra + // rows and collide with the output below. + const headlined = pending?.description !== undefined && pending.description !== '' + const commandInBody = pending !== undefined && (headlined || this.result === undefined) + if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`)) + if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd))) + if (this.resultView?.card === 'terminal') { + if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) + if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) + if (this.resultView.signal !== undefined) { + lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) + } + } else if (this.result !== undefined) { + lines.push(...displayText(contentText(this.result.content)).split('\n')) + } + return lines.filter(Boolean) + } + if (view.card === 'diff') { + // The header no longer names the file, so each diff keeps its own path + // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + let added = 0 + let removed = 0 + const hunks = view.diffs.flatMap((diff, index) => { + if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length + added += displayText(diff.newText).split('\n').length + return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + }) + const files = view.diffs.length + const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + return [...hunks, footer] + } + const content = view.content ?? this.result?.content + const lines: string[] = [] + // The presenter title headlines the body now that the header is a fixed + // `Tool / ` frame (a terminal card keeps its command $-line instead). + // Skip it when it only repeats the tool name (the fallback presenter for a + // tool with no presentCall, or an unknown tool), which the header already shows. + const bodyTitle = this.bodyTitle() + if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle)) + if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) + const rawInput = this.result === undefined && this.callView.card === 'generic' + ? this.callView.rawInput + : undefined + if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) + return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) + } +} + +/** The plan/todo panel rendered above the prompt. */ +export class TodoComponent implements Component { + private todos: readonly TodoItem[] = [] + + constructor(private readonly palette: Palette) {} + + /** + * Replace the rendered plan items. + * @param todos - The current todo items. + */ + update(todos: readonly TodoItem[]): void { + this.todos = todos + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return [] + const lines = [this.palette.bold(this.palette.accent('Plan'))] + for (const todo of this.todos) { + const prefix = todo.status === 'completed' + ? this.palette.success('✓') + : todo.status === 'in_progress' + ? this.palette.warning('●') + : this.palette.dim('○') + const content = displayText(todo.content) + const text = todo.status === 'completed' ? this.palette.muted(content) : content + lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) + } + return ['', ...lines] + } +} diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts new file mode 100644 index 0000000000..a0114be972 --- /dev/null +++ b/packages/ui/tui/src/config.ts @@ -0,0 +1,213 @@ +/** + * Serializable configuration and defaults for the pi-tui terminal mode. Loader + * schema validation normally fills defaults; {@link resolveTuiConfig} applies + * the same defaults for direct callers that bypass the Loader. + * @module @deepseek-ai/dsh-tui/config + */ + +import z from 'schemastery' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './file-autocomplete.ts' + +/** Theme and prompt-template settings for the pi-tui terminal mode. */ +export interface TuiThemeConfig { + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Paint the startup banner with the 24-bit DeepSeek brand gradient. */ + truecolor?: boolean + /** Left-aligned template on the row above the editor. */ + leftPrompt?: string + /** Right-aligned template on the row above the editor. */ + rightPrompt?: string + /** Template used as the editor's first-line prefix. */ + inputPrompt?: string + /** Static placeholder shown in an empty editor while the agent is running. */ + inputPlaceholder?: string +} + +/** Interaction and presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question panel. */ + maxQuestionOptions?: number + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ + questionDialogWidth?: number + /** User-question panel maximum height in terminal rows. */ + questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number + /** Maximum fuzzy file candidates displayed for one `@` query. */ + fileSearchMaxResults?: number + /** Maximum paths retained in one `@` workspace index. */ + fileSearchMaxEntries?: number + /** Directory basenames excluded from `@` traversal and completion. */ + fileSearchExcludedDirectories?: string[] + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig + /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ + title?: string +} + +const showReasoningSchema = z.boolean().default(true) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(200) +const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const modelDialogWidthSchema = z.number().step(1).min(20).default(76) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) +const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) +const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) +const showHardwareCursorSchema = z.boolean().default(false) +const colorSchema = z.boolean().default(true) +// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. +const truecolorSchema = z.boolean() +const DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}' +const DEFAULT_RIGHT_PROMPT = '${timing}' +const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}' +const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel' +const TuiThemeConfigSchema: z = z.object({ + color: colorSchema, + truecolor: truecolorSchema, + leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT), + rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT), + inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT), + inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER), +}) +const titleSchema = z.string().default('DeepSeek Harness') + +const tuiConfigSchemaFields = { + showReasoning: showReasoningSchema, + maxToolOutputLines: maxToolOutputLinesSchema, + maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, + questionDialogWidth: questionDialogWidthSchema, + questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, + showHardwareCursor: showHardwareCursorSchema, + theme: TuiThemeConfigSchema, + title: titleSchema, +} + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) + +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ + welcome?: string + /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ + sessionId?: string + /** + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. + */ + resumeCommand?: string +} + +/** Schemastery schema for the full plugin configuration. */ +export const Config: z = z.object({ + welcome: z.string(), + sessionId: z.string().default('main'), + resumeCommand: z.string(), + showReasoning: tuiConfigSchemaFields.showReasoning, + maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, + maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, + questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, + questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, + modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, + modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, + fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, + fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, + showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, + theme: tuiConfigSchemaFields.theme, + title: tuiConfigSchemaFields.title, +}) + +/** Fully defaulted TUI theme settings. */ +export interface ResolvedTuiThemeConfig { + color: boolean + truecolor: boolean + leftPrompt: string + rightPrompt: string + inputPrompt: string + inputPlaceholder: string +} + +/** Fully defaulted TUI presentation settings. */ +export interface ResolvedTuiConfig { + showReasoning: boolean + maxToolOutputLines: number + maxQuestionOptions: number + maxModelOptions: number + maxResumeOptions: number + questionDialogWidth: number + questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number + fileSearchMaxResults: number + fileSearchMaxEntries: number + fileSearchExcludedDirectories: string[] + showHardwareCursor: boolean + theme: ResolvedTuiThemeConfig + title: string +} + +/** + * Apply direct-call defaults after Loader schema validation has normally run. + * + * @param config - Deployment-provided terminal presentation settings. + * @returns Complete settings consumed by the TUI renderer. + */ +export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { + return { + showReasoning: config?.showReasoning ?? true, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxQuestionOptions: config?.maxQuestionOptions ?? 8, + maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, + questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 76, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], + showHardwareCursor: config?.showHardwareCursor ?? false, + theme: { + color: config?.theme?.color ?? true, + truecolor: config?.theme?.truecolor ?? false, + leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT, + rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT, + inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT, + inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER, + }, + title: config?.title ?? 'DeepSeek Harness', + } +} diff --git a/packages/ui/tui/src/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts similarity index 98% rename from packages/ui/tui/src/overlay-manager.ts rename to packages/ui/tui/src/extension/overlay-manager.ts index efe8716777..59d84ff3d5 100644 --- a/packages/ui/tui/src/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -3,12 +3,12 @@ * * The manager serializes modal ownership, guards extension callbacks, and * settles every queued or active operation before terminal teardown. - * @module @deepseek-ai/dsh-tui/overlay-manager + * @module @deepseek-ai/dsh-tui/extension/overlay-manager */ import { Service, type Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TuiExtensionService } from './index.ts' +import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, @@ -26,7 +26,7 @@ import type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './types.ts' /** pi-tui operations retained by the front door instead of exposed to plugins. */ export interface TuiOverlayDriver { diff --git a/packages/ui/tui/src/extension.ts b/packages/ui/tui/src/extension/types.ts similarity index 99% rename from packages/ui/tui/src/extension.ts rename to packages/ui/tui/src/extension/types.ts index d6cb7bc4e4..adb45d5430 100644 --- a/packages/ui/tui/src/extension.ts +++ b/packages/ui/tui/src/extension/types.ts @@ -5,7 +5,7 @@ * the live pi-tui tree, focus controller, overlay handles, or terminal * lifecycle. Registrations and open overlays remain owned by the calling * Cordis fiber. - * @module @deepseek-ai/dsh-tui/extension + * @module @deepseek-ai/dsh-tui/extension/types */ /** Terminal component shape accepted from a trusted TUI extension. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a88abb5586..1dffff23e9 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,41 +5,30 @@ * @module @deepseek-ai/dsh-tui */ +import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, + CURSOR_MARKER, Editor, - Input, Key, - Loader, - Markdown, Spacer, Text, TUI, ProcessTerminal, - SelectList, matchesKey, truncateToWidth, visibleWidth, - wrapTextWithAnsi, - type Component, - type AutocompleteItem, - type AutocompleteProvider, - type AutocompleteSuggestions, type EditorTheme, - type Focusable, - type MarkdownTheme, - type SelectItem, - type SelectListTheme, type SlashCommand, type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' -import z from 'schemastery' import { + assembleContextFor, installAgentLlmTarget, type Agent, type AgentLlmTarget, @@ -49,31 +38,22 @@ import { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' -import type {} from '@deepseek-ai/dsh-commands' -import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' -import type { - ContentBlock, - LlmModelInfo, - LlmModelReasoningInfo, - ReasoningEffortId, - StreamChunk, - TokenUsage, -} from '@deepseek-ai/dsh-llm' +import type { CommandResult } from '@deepseek-ai/dsh-commands' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { renderUnknownXml } from './xml-tool-output.ts' import type {} from '@deepseek-ai/dsh-llm-retry' +import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { displayPromptContent, SessionId, - type JsonValue, type Session, type SessionEvent, type SessionHeader, - type TodoItem, } from '@deepseek-ai/dsh-session' -import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' +import { foldGoal } from '@deepseek-ai/dsh-goal' import { - formatSessionReferenceMention, parseSessionReferenceText, - type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { @@ -83,30 +63,104 @@ import type { // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' -import type { - FileDiff, - TerminalCallView, - ToolCallView, - ToolDefinition, - ToolResultView, -} from '@deepseek-ai/dsh-tools' +import type { SkillService } from '@deepseek-ai/dsh-skill' import { UserInteractionError, type AskUserQuestionAnswer, type AskUserQuestionAnswerItem, - type AskUserQuestionItem, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, -} from './overlay-manager.ts' +} from './extension/overlay-manager.ts' +import { + parseTuiPromptTemplate, + renderTuiPromptTemplate, + type TuiPromptValueHandle, +} from './prompt.ts' import type { TuiOverlayRequest, TuiOverlaySession, TuiTheme, -} from './extension.ts' +} from './extension/types.ts' +import { displayInlineText, displayText } from './components/text.ts' +import { createPalette, markdownTheme, selectTheme } from './components/theme.ts' +import { contentText, parseArguments } from './components/content.ts' +import { + cacheHitRate, + formatTokens, + recordEventUsage, + sessionTokens, +} from './session/tokens.ts' +import { + fadeGlyph, + formatQueuedStatus, + openStepPhase, + openTurn, + pulseLevel, + runningPhaseGlyph, + STATUS_ANIMATION_INTERVAL_MS, + STATUS_FADE_MS, + TIMING_BUCKET_GLYPHS, + type StepPosition, +} from './session/timing.ts' +import { + resolveTuiConfig, + type Config, +} from './config.ts' +import { + HeaderComponent, + StreamingAssistantComponent, + ToolCardComponent, + TodoComponent, + UserMessageComponent, +} from './components/transcript.ts' +import { + compactTargetLabel, + diagnosticMeter, + formatDiagnosticCount, + formatDiagnosticNumber, + formatDiagnosticTime, + initialTarget, + ModelDialog, + QuestionDialog, + readModelChoices, + ResumePicker, + StatusCardComponent, + PromptContextComponent, + summarizeResumeCandidate, + targetLabel, + targetReasoningLabel, + type ModelChoice, + type ModelDialogSelection, + type ResumeCandidate, + type StatusCardRow, +} from './components/dialogs.ts' +import { + parseSkillCommand, + renderSkillInvocation, + SKILL_COMMAND_PREFIX, +} from './skill-invocation.ts' +import { ReferenceAutocompleteProvider } from './autocomplete.ts' +import { WorkspaceFileSearch } from './file-autocomplete.ts' + +export { TuiPromptService } from './prompt.ts' +export { renderSkillInvocation } from './skill-invocation.ts' +export { + resolveTuiConfig, + TuiConfigSchema, + Config, + type ResolvedTuiConfig, + type ResolvedTuiThemeConfig, + type TuiConfig, + type TuiThemeConfig, +} from './config.ts' +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './file-autocomplete.ts' export type { TuiComponent, @@ -122,7 +176,7 @@ export type { TuiOverlayState, TuiTheme, TuiViewport, -} from './extension.ts' +} from './extension/types.ts' declare module 'cordis' { interface Context { @@ -167,165 +221,13 @@ export abstract class TuiExtensionService extends Service { */ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession } -import { - activeAtToken, - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, - formatFileMention, - WorkspaceFileSearch, -} from './file-autocomplete.ts' - -export { - DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, - DEFAULT_FILE_SEARCH_MAX_ENTRIES, - DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter', 'tuiPrompt'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** Interaction and presentation settings for the pi-tui terminal mode. */ -export interface TuiConfig { - /** Render model reasoning blocks. */ - showReasoning?: boolean - /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ - maxToolOutputLines?: number - /** Maximum options visible at once in a user-question panel. */ - maxQuestionOptions?: number - /** Maximum models visible at once in the model selector. */ - maxModelOptions?: number - /** Maximum sessions visible at once in the resume selector. */ - maxResumeOptions?: number - /** User-question panel width in terminal columns, clamped to the terminal. */ - questionDialogWidth?: number - /** User-question panel maximum height in terminal rows. */ - questionDialogMaxHeight?: number - /** Model-selector width in terminal columns. */ - modelDialogWidth?: number - /** Model-selector maximum height in terminal rows. */ - modelDialogMaxHeight?: number - /** Maximum fuzzy file candidates displayed for one `@` query. */ - fileSearchMaxResults?: number - /** Maximum paths retained in one `@` workspace index. */ - fileSearchMaxEntries?: number - /** Directory basenames excluded from `@` traversal and completion. */ - fileSearchExcludedDirectories?: string[] - /** Show the terminal's hardware cursor at the pi editor's IME marker. */ - showHardwareCursor?: boolean - /** Apply the built-in ANSI color palette. */ - color?: boolean - /** - * Paint the startup banner's product name in the DeepSeek brand gradient - * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the - * flat accent color when either is off. Unset auto-detects `COLORTERM` at the - * process boundary, so most deployments leave it unset. - */ - truecolor?: boolean - /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ - title?: string -} - -const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) -const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const maxModelOptionsSchema = z.number().step(1).min(1).default(8) -const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) -const questionDialogWidthSchema = z.number().step(1).min(20).default(200) -const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const modelDialogWidthSchema = z.number().step(1).min(20).default(76) -const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) -const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) -const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) -const showHardwareCursorSchema = z.boolean().default(false) -const colorSchema = z.boolean().default(true) -// No default: an unset value auto-detects truecolor from COLORTERM in `apply`. -const truecolorSchema = z.boolean() -const titleSchema = z.string().default('DeepSeek Harness') - -const tuiConfigSchemaFields = { - showReasoning: showReasoningSchema, - maxToolOutputLines: maxToolOutputLinesSchema, - maxQuestionOptions: maxQuestionOptionsSchema, - maxModelOptions: maxModelOptionsSchema, - maxResumeOptions: maxResumeOptionsSchema, - questionDialogWidth: questionDialogWidthSchema, - questionDialogMaxHeight: questionDialogMaxHeightSchema, - modelDialogWidth: modelDialogWidthSchema, - modelDialogMaxHeight: modelDialogMaxHeightSchema, - fileSearchMaxResults: fileSearchMaxResultsSchema, - fileSearchMaxEntries: fileSearchMaxEntriesSchema, - fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, - showHardwareCursor: showHardwareCursorSchema, - color: colorSchema, - truecolor: truecolorSchema, - title: titleSchema, -} - -/** Schemastery schema for presentation settings embedded by app bundles. */ -export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) - -/** Serializable plugin configuration. */ -export interface Config extends TuiConfig { - /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */ - welcome?: string - /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ - sessionId?: string - /** - * Shell command fallback printed on exit or after selecting a session when - * the host cannot hand off in place. Every `{session}` becomes the selected - * id; the TUI never executes this text. Absent disables only the fallback, - * not the interactive selector. - */ - resumeCommand?: string -} - -export const Config: z = z.object({ - welcome: z.string(), - sessionId: z.string().default('main'), - resumeCommand: z.string(), - showReasoning: tuiConfigSchemaFields.showReasoning, - maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, - maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, - maxModelOptions: tuiConfigSchemaFields.maxModelOptions, - maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, - questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, - questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, - modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, - modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, - fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, - fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, - showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, - color: tuiConfigSchemaFields.color, - truecolor: tuiConfigSchemaFields.truecolor, - title: tuiConfigSchemaFields.title, -}) - -/** Fully defaulted TUI presentation settings. */ -export interface ResolvedTuiConfig { - showReasoning: boolean - maxToolOutputLines: number - maxQuestionOptions: number - maxModelOptions: number - maxResumeOptions: number - questionDialogWidth: number - questionDialogMaxHeight: number - modelDialogWidth: number - modelDialogMaxHeight: number - fileSearchMaxResults: number - fileSearchMaxEntries: number - fileSearchExcludedDirectories: string[] - showHardwareCursor: boolean - color: boolean - truecolor: boolean - title: string -} - /** Runtime boundary used by the interactive TUI. */ export interface TuiRuntime { /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ @@ -333,732 +235,76 @@ export interface TuiRuntime { /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void /** - * Override the footer's logical working-directory label without changing the session directory used by tools. + * Override the prompt's logical working-directory label without changing the session directory used by tools. * @param cwd - Operational working directory from the session header. * @returns Unescaped label; the TUI makes terminal controls visible. */ formatCwd?: (cwd: string | undefined) => string + /** + * Override the Git branch shown in the prompt context line; production resolves it once at mount. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped branch name, or `undefined` outside a Git worktree. + */ + gitBranch?: (cwd: string) => string | undefined /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ handoffResume?: TuiResumeHost['handoff'] } -/** - * Apply direct-call defaults after Loader schema validation has normally run. - * - * @param config - Deployment-provided terminal presentation settings. - * @returns Complete settings consumed by the TUI renderer. - */ -export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { - return { - showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 6, - maxQuestionOptions: config?.maxQuestionOptions ?? 8, - maxModelOptions: config?.maxModelOptions ?? 8, - maxResumeOptions: config?.maxResumeOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 200, - questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, - modelDialogWidth: config?.modelDialogWidth ?? 76, - modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, - fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, - fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], - showHardwareCursor: config?.showHardwareCursor ?? false, - color: config?.color ?? true, - truecolor: config?.truecolor ?? false, - title: config?.title ?? 'DeepSeek Harness', +/** Editor that shows a placeholder without making it editable content. */ +class HintEditor extends Editor { + hint: string | undefined + hintPrefix = '' + + override render(width: number): string[] { + const lines = super.render(width) + if (this.hint === undefined || this.getText() !== '') return lines + const content = lines[0] + /* v8 ignore next -- Editor always renders one content row. */ + if (content === undefined) return lines + const padding = ' '.repeat(this.getPaddingX()) + /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ + const marker = this.focused ? CURSOR_MARKER : '' + const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) + const placeholder = truncateToWidth(this.hint, available, '') + const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) + lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` + return lines } } -interface Palette { - accent: (text: string) => string - accent2: (text: string) => string - text: (text: string) => string - muted: (text: string) => string - dim: (text: string) => string - success: (text: string) => string - warning: (text: string) => string - error: (text: string) => string - code: (text: string) => string - added: (text: string) => string - removed: (text: string) => string - bold: (text: string) => string - italic: (text: string) => string - underline: (text: string) => string - strike: (text: string) => string - /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ - selected: (text: string) => string -} - -function ansi(open: string, close: string, enabled: boolean): (text: string) => string { - return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text -} - -const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu -const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu -const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu -const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu -const BRACKETED_PASTE_START = '\u001B[200~' -const BRACKETED_PASTE_END = '\u001B[201~' - -/** - * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. - * Line feeds remain structural so transcript and tool output retain their layout. - */ -function displayText(text: string): string { - return text.replace(TERMINAL_CONTROL_PATTERN, control => - `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) -} - -/** Escape external controls for terminal fields that must remain on one line. */ -function displayInlineText(text: string): string { - return displayText(text).replaceAll('\n', '\\x0a') -} - -/** Remove terminal controls from clipboard text before an editable field stores it. */ -function sanitizePastedText(text: string): string { - return text - .replace(TERMINAL_OSC_PATTERN, '') - .replace(TERMINAL_CSI_PATTERN, '') - .replace(TERMINAL_ESCAPE_PATTERN, '') - .replace(TERMINAL_CONTROL_PATTERN, '') -} - -/** - * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR - * attributes, which every terminal remaps to its active color scheme. Body - * `text` stays the terminal's default foreground so it reads on light and dark - * backgrounds alike; grouping uses foreground-only gutter bars and reverse - * video rather than fixed background fills. - */ -function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { - return { - accent: ansi('94', '39', enabled), - accent2: ansi('95', '39', enabled), - text: text => text, - muted: ansi('90', '39', enabled), - // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 - // (bright black / gray) which renders as a readable muted tone on any scheme. - dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), - success: ansi('32', '39', enabled), - warning: ansi('33', '39', enabled), - error: ansi('31', '39', enabled), - // ANSI 36 (cyan) is difficult to read on a light background — use - // ANSI 34 (blue) which is legible on both light and dark schemes. - code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), - added: ansi('32', '39', enabled), - removed: ansi('31', '39', enabled), - bold: ansi('1', '22', enabled), - italic: ansi('3', '23', enabled), - underline: ansi('4', '24', enabled), - strike: ansi('9', '29', enabled), - selected: ansi('7', '27', enabled), - } -} - -/** - * DeepSeek brand gradient stops (indigo → light blue) taken from the - * deepseek.com logo, painted across the startup banner's product name on - * truecolor terminals. Fixed brand identity, deliberately outside the - * theme-adaptive {@link Palette}. - */ -const BRAND_GRADIENT = [ - [77, 107, 254], // #4D6BFE - [57, 130, 255], // #3982FF - [36, 152, 255], // #2498FF -] as const - -/** - * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear - * interpolation across its stops. - * - * @param t - Position along the gradient; clamped to [0, 1]. - * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255. - */ -function brandColorAt(t: number): readonly [number, number, number] { - const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1) - const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2) - const local = span - index - // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds. - const from = BRAND_GRADIENT[index] as readonly [number, number, number] - const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number] - return [ - Math.round(from[0] + (to[0] - from[0]) * local), - Math.round(from[1] + (to[1] - from[1]) * local), - Math.round(from[2] + (to[2] - from[2]) * local), - ] -} - -/** - * Paint `text` left-to-right in the DeepSeek brand gradient with per-character - * 24-bit foreground codes, resetting to the default foreground at the end. - * Foreground-only, so it stays legible on any terminal background; the caller - * gates it on truecolor support and wraps it in bold. - * - * @param text - Text to colorize; sampled once per character. - * @returns `text` wrapped in truecolor SGR foreground codes. - */ -function gradientText(text: string): string { - // The sole caller passes the ASCII product name, so UTF-16 unit iteration - // samples exactly one color per visible letter. - const last = Math.max(1, text.length - 1) - let painted = '' - for (let index = 0; index < text.length; index += 1) { - const [r, g, b] = brandColorAt(index / last) - painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}` - } - return `${painted}\x1b[39m` -} - -function markdownTheme(palette: Palette): MarkdownTheme { - return { - heading: text => palette.accent(text), - link: text => palette.accent(text), - // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. - /* v8 ignore next */ - linkUrl: text => palette.dim(text), - code: text => palette.code(text), - codeBlock: text => palette.text(text), - codeBlockBorder: text => palette.dim(text), - quote: text => palette.muted(text), - quoteBorder: text => palette.accent2(text), - hr: text => palette.dim(text), - listBullet: text => palette.accent(text), - bold: text => palette.bold(text), - italic: text => palette.italic(text), - strikethrough: text => palette.strike(text), - underline: text => palette.underline(text), - } -} - -function selectTheme(palette: Palette): SelectListTheme { - return { - selectedPrefix: palette.accent, - selectedText: palette.accent, - description: palette.muted, - scrollInfo: palette.dim, - noMatch: palette.warning, - } -} - -function dialogSelectTheme(palette: Palette): SelectListTheme { - return { - ...selectTheme(palette), - selectedText: text => palette.selected(palette.accent(text)), - } -} - -function contentText(content: readonly ContentBlock[]): string { - const parts: string[] = [] - for (const block of content) { - switch (block.type) { - case 'text': - case 'reasoning': - parts.push(block.text) - break - case 'tool-call': - parts.push(`${block.name}(${block.arguments})`) - break - case 'tool-result': - parts.push(contentText(block.content)) - break - default: { - const rawType = (block as { type?: unknown }).type - parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) - break - } - } - } - return parts.join('') -} - -function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { - return content - .filter((block): block is Extract => block.type === type) - .map(block => block.text) - .join('\n\n') -} - -interface ModelChoice extends AgentLlmTarget { - modelName: string - description?: string - reasoning?: LlmModelReasoningInfo -} - -interface ModelDialogSelection { - choice: ModelChoice - reasoningEffort: ReasoningEffortId | undefined -} - -function targetLabel(target: AgentLlmTarget): string { - return `${target.provider}/${target.model}` -} - -function compactTargetLabel(target: AgentLlmTarget): string { - return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` -} - -function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { - if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' - return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort -} - -function initialTarget(agent: Agent): AgentLlmTarget | undefined { - const logged = agent.session.requestHeader()?.config - if (logged !== undefined) { - if (logged.reasoningEffort === undefined) { - return { provider: logged.provider, model: logged.model } - } - return { - provider: logged.provider, - model: logged.model, - reasoningEffort: logged.reasoningEffort, - } - } - if (agent.options.provider === undefined || agent.options.model === undefined) return undefined - return { provider: agent.options.provider, model: agent.options.model } -} - -async function readModelChoices( - ctx: Context, - current: AgentLlmTarget | undefined, -): Promise { - const providers = ctx.llm.listProviders() - const groups = await Promise.all(providers.map(async (provider) => { - const advertised = await ctx.llm.listModels(provider.id) - const models: LlmModelInfo[] = [...advertised] - if ( - current?.provider === provider.id - && !models.some(model => model.id === current.model) - ) { - models.push({ provider: provider.id, id: current.model, name: current.model }) - } - return Promise.all(models.map(async (model): Promise => { - const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning - return { - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - })) - return groups.flat() -} - -/** Milliseconds between banner sweep-reveal frames (~60 fps). */ -const BANNER_REVEAL_INTERVAL_MS = 15 - -/** Number of sweep frames the banner reveal spreads the terminal width over. */ -const BANNER_REVEAL_STEPS = 24 - -/** - * Borderless startup banner: product title, an optional configured subtitle, - * and the model/session detail line. No box frame — each line renders as plain - * left-padded text (matching transcript notices) so it reads on any theme. - */ -class HeaderComponent implements Component { - /** Columns of the banner currently revealed; `undefined` renders it whole. */ - private revealWidth: number | undefined - - constructor( - private readonly agent: Agent, - private readonly subtitle: () => string | undefined, - private readonly palette: Palette, - private readonly gradient: boolean, - private readonly currentModel: () => string | undefined, - ) {} - - /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */ - setRevealWidth(width: number | undefined): void { - this.revealWidth = width - } - - invalidate(): void {} - - render(width: number): string[] { - const usable = Math.max(1, width - 2) - const name = this.gradient - ? this.palette.bold(gradientText('DEEPSEEK')) - : this.palette.bold(this.palette.accent('DEEPSEEK')) - const title = `${name} ${this.palette.bold('HARNESS')}` - const model = displayText(this.currentModel() ?? 'model unset') - const detail = `${model} • ${displayText(this.agent.session.id)}` - const subtitle = this.subtitle() - const lines = [ - title, - ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], - this.palette.dim(detail), - ] - .flatMap(line => wrapTextWithAnsi(line, usable)) - .map(line => ` ${truncateToWidth(line, usable, '')}`) - if (this.revealWidth === undefined) return lines - const revealed = this.revealWidth - return lines.map(line => truncateToWidth(line, revealed, '')) - } -} - -/** Milliseconds between elapsed-time refreshes of the running status line. */ -const STATUS_ELAPSED_INTERVAL_MS = 1000 - -/** Steering/cancel affordance shown on every running status line. */ -const STATUS_HINT = 'Enter sends steering, Esc cancels' - -/** - * Fine-grained activity of a running turn, derived in the TUI from session - * lifecycle events for the status line. It is presentation-only, not a durable - * agent state: `waiting` spans a step from its `step/start` until the first - * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas, - * and `executing` covers tool calls until the next step begins. - */ -type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing' - -/** - * Live controller for the running status line: its {@link Loader}, the derived - * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer - * that refreshes it. Present only while the turn runs; `undefined` when idle. - */ interface RunningStatus { - loader: Loader - phase: TurnPhase - phaseStartedAt: number - stepStartedAt: number + turn: number | undefined + timer: ReturnType + /** Render clock when the turn began; origin of the glyph fade-in. */ + startedAt: number + /** The most recently rendered phase glyph, handed to the fade-out. */ + lastGlyph: string +} + +/** A running glyph fading out after its turn ended, before the caret returns. */ +interface FadingStatus { + glyph: string + /** Render clock when the turn ended; origin of the glyph fade-out. */ + endedAt: number timer: ReturnType } -/** Status-line label for each {@link TurnPhase}. */ -const TURN_PHASE_LABELS: Record = { - waiting: 'Waiting for the first token', - thinking: 'Thinking', - responding: 'Responding', - executing: 'Executing tools', +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: TuiOverlaySession | undefined } -/** - * Format a non-negative elapsed span as a compact status duration: whole - * seconds under a minute (`8s`), else minutes and zero-padded seconds - * (`1m05s`). - * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero. - * @returns The compact duration string. - */ -function formatStatusDuration(elapsedMs: number): string { - const total = Math.floor(Math.max(0, elapsedMs) / 1000) - if (total < 60) return `${total}s` - return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s` -} - -/** - * Compose the running status-line text from the current phase, its timers, and - * the queued-steering badge. The waiting phase spans the whole step so it shows - * one duration; later phases show time in the phase plus the running step - * total, and a non-zero `queued` count surfaces as a badge before the hint. - * @param phase - The current turn phase. - * @param phaseMs - Elapsed time in the current phase, in milliseconds. - * @param stepMs - Elapsed time in the current step, in milliseconds. - * @param queued - Count of pending steering messages; zero hides the badge. - * @returns The status-line text, including the steering/cancel hint. - */ -function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string { - const timing = phase === 'waiting' - ? formatStatusDuration(stepMs) - : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}` - const badge = queued > 0 ? `${queued} queued · ` : '' - return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}` -} - -/** - * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so - * it renders legibly on any terminal background — unlike a filled block whose - * body text would collide with the theme's default foreground. - */ -class GutterBox implements Component { - protected readonly children: Component[] = [] - - constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {} - - addChild(child: Component): void { - this.children.push(child) - } - - invalidate(): void { - for (const child of this.children) child.invalidate() - } - - render(width: number): string[] { - const inner = Math.max(1, width - 2) - const body: string[] = [] - for (const child of this.children) for (const line of child.render(inner)) body.push(line) - // Every caller adds a non-empty title/label child, so an all-empty box is unreachable; - // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars. - /* v8 ignore next */ - if (body.length === 0) return [] - const bar = this.barFn('▌') - const pad = Array.from({ length: this.paddingY }, () => '') - return [...pad, ...body, ...pad].map(line => `${bar} ${line}`) - } -} - -class UserMessageComponent extends GutterBox { - constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { - super(value => palette.accent(value)) - this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0)) - this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { - preserveOrderedListMarkers: true, - preserveBackslashEscapes: true, - })) - } -} - -class AssistantMessageComponent extends Container { - constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { - super() - const reasoning = displayText(textBlocks(content, 'reasoning').trim()) - const text = displayText(textBlocks(content, 'text').trim()) - if (reasoning && showReasoning) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) - this.addChild(new Markdown(reasoning, 1, 0, mdTheme, { - color: value => palette.muted(value), - italic: true, - })) - } - if (text) { - this.addChild(new Spacer(1)) - this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0)) - this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) })) - } - } -} - -interface StreamingBlock { - type: string - text: string -} - -class StreamingAssistantComponent extends Container { - private readonly blocks = new Map() - - constructor( - private showReasoning: boolean, - private readonly palette: Palette, - private readonly mdTheme: MarkdownTheme, - ) { - super() - } - - update(chunk: StreamChunk): void { - if (chunk.type === 'block-start') { - this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) - } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { - const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' - const block = this.blocks.get(chunk.index) ?? { type, text: '' } - block.text += chunk.text - this.blocks.set(chunk.index, block) - } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { - this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) - } - this.rebuild() - } - - setShowReasoning(show: boolean): void { - this.showReasoning = show - this.rebuild() - } - - private rebuild(): void { - this.clear() - const content: ContentBlock[] = [...this.blocks.entries()] - .sort(([left], [right]) => left - right) - .flatMap(([, block]) => { - if (block.type === 'text') return [{ type: 'text', text: block.text }] - if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] - return [] - }) - const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) - for (const child of component.children) this.addChild(child) - } -} - -interface ParsedArguments { - value: unknown - valid: boolean -} - -function parseArguments(raw: string): ParsedArguments { - try { - return { value: JSON.parse(raw), valid: true } - } catch { - return { value: raw, valid: false } - } -} - -function pretty(value: unknown): string { - if (typeof value === 'string') return displayText(value) - // The lib declaration narrows `unknown` to a string-returning overload, but - // JSON.stringify returns undefined for runtime values such as symbols. - const serialized = JSON.stringify(value, null, 2) as string | undefined - return displayText(serialized ?? String(value)) -} - -function diffLines(diff: FileDiff, palette: Palette): string[] { - const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) - } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) - return lines -} - -class ToolCardComponent implements Component { - private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined - private expanded = false - private callView: ToolCallView - private resultView: ToolResultView | undefined - - constructor( - private readonly name: string, - private readonly parsed: ParsedArguments, - private readonly definition: ToolDefinition | undefined, - private readonly maxOutputLines: number, - private readonly palette: Palette, - ) { - this.callView = this.presentCall() - } - - private presentCall(): ToolCallView { - if (this.parsed.valid && this.definition?.presentCall) { - try { - const view = this.definition.presentCall(this.parsed.value) - if (view !== undefined) return view - } catch (error: unknown) { - return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } - } - } - return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } - } - - updateResult(event: Extract['data']): void { - this.result = { - content: [...event.content], - isError: event.isError, - ...event.meta !== undefined ? { meta: event.meta } : {}, - } - if (this.parsed.valid && this.definition?.presentResult) { - try { - const view = this.definition.presentResult(this.parsed.value, this.result) - if (view !== undefined) this.resultView = view - } catch (error: unknown) { - this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } - } - } - } - - setExpanded(expanded: boolean): void { - this.expanded = expanded - } - - invalidate(): void {} - - render(width: number): string[] { - const isError = this.result?.isError ?? false - const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') - const body = this.renderBody() - const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') - const headLines = Math.ceil(this.maxOutputLines / 2) - const tailLines = this.maxOutputLines - headLines - const visibleBody = this.expanded || body.length <= this.maxOutputLines - ? body - : [ - ...body.slice(0, headLines), - this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), - ...body.slice(body.length - tailLines), - ] - const barFn = this.result === undefined - ? this.palette.warning - : isError ? this.palette.error : this.palette.success - const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0) - box.addChild(new Text(this.palette.bold(title), 0, 0)) - if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) - return box.render(width) - } - - private title(): string { - return this.resultView?.title ?? this.callView.title - } - - private renderBody(): string[] { - const view = this.resultView ?? this.callView - if (view.card === 'terminal') { - const pending = this.callView.card === 'terminal' ? this.callView : undefined - const lines: string[] = [] - if (pending?.description) lines.push(this.palette.muted(displayText(pending.description))) - if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd))) - if (this.resultView?.card === 'terminal') { - if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) - if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) - if (this.resultView.signal !== undefined) { - lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) - } - } else if (this.result === undefined) { - // A pending terminal view is the call view itself; TerminalCallView requires a title. - lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`)) - } else { - lines.push(...displayText(contentText(this.result.content)).split('\n')) - } - return lines.filter(Boolean) - } - if (view.card === 'diff') { - return view.diffs.flatMap((diff, index) => [ - ...index > 0 ? [''] : [], - ...diffLines(diff, this.palette), - ]) - } - const content = view.content ?? this.result?.content - const lines: string[] = [] - if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) - const rawInput = this.result === undefined && this.callView.card === 'generic' - ? this.callView.rawInput - : undefined - if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) - return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) - } -} - -class TodoComponent implements Component { - private todos: readonly TodoItem[] = [] - - constructor(private readonly palette: Palette) {} - - update(todos: readonly TodoItem[]): void { - this.todos = todos - } - - invalidate(): void {} - - render(width: number): string[] { - if (this.todos.length === 0) return [] - const lines = [this.palette.bold(this.palette.accent('Plan'))] - for (const todo of this.todos) { - const prefix = todo.status === 'completed' - ? this.palette.success('✓') - : todo.status === 'in_progress' - ? this.palette.warning('●') - : this.palette.dim('○') - const content = displayText(todo.content) - const text = todo.status === 'completed' ? this.palette.muted(content) : content - lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) - } - return ['', ...lines] - } -} - -function formatTokens(value: number): string { - if (value < 1_000) return String(value) - if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` - if (value < 1_000_000) return `${Math.round(value / 1_000)}k` - return `${(value / 1_000_000).toFixed(1)}m` +/** Lifecycle handle for a mounted interactive terminal channel. */ +export interface TuiController { + /** Stop rendering, restore the terminal, and reject pending questions. */ + dispose(): Promise } function formatCwd(cwd: string | undefined): string { @@ -1072,834 +318,25 @@ function formatCwd(cwd: string | undefined): string { return cwd } -/** - * Running token totals for the footer, keyed per turn/step so replayed or - * re-emitted usage replaces rather than double-counts; `input` is uncached - * input, cache buckets are disjoint. - */ -interface SessionTokenTotals { - input: number - output: number - cacheRead: number - cacheWrite: number - readonly byStep: Map -} - -function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { - const key = `${turn}:${step}` - const previous = totals.byStep.get(key) - if (previous !== undefined) { - totals.input -= previous.inputTokens - totals.output -= previous.outputTokens - totals.cacheRead -= previous.cacheReadTokens ?? 0 - totals.cacheWrite -= previous.cacheWriteTokens ?? 0 - } - totals.byStep.set(key, usage) - totals.input += usage.inputTokens - totals.output += usage.outputTokens - totals.cacheRead += usage.cacheReadTokens ?? 0 - totals.cacheWrite += usage.cacheWriteTokens ?? 0 -} - -function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { - if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) - } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { - recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) - } -} - -/** - * Share of billed input (prompt) tokens served from the provider cache, as an - * integer percent, or `undefined` before any input is billed (avoids 0/0 and a - * meaningless rate on an empty session). - */ -function cacheHitRate(totals: SessionTokenTotals): number | undefined { - const billedInput = totals.input + totals.cacheRead + totals.cacheWrite - if (billedInput === 0) return undefined - return Math.round((totals.cacheRead / billedInput) * 100) -} - -function sessionTokens(session: Session): SessionTokenTotals { - const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } - for (const event of session.events) { - recordEventUsage(totals, event) - } - return totals -} - -function formatDiagnosticNumber(value: number): string { - return value.toLocaleString('en-US') -} - -function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') -} - -function formatDiagnosticCount(value: number, singular: string): string { - return `${String(value)} ${singular}${value === 1 ? '' : 's'}` -} - -function diagnosticMeter(percent: number, palette: Palette): string { - const width = 16 - const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) - return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` -} - -type StatusCardRow = readonly [label: string, value: string] - -/** Bordered, grouped field card for one point-in-time status snapshot. */ -class StatusCardComponent implements Component { - constructor( - private readonly groups: readonly (readonly StatusCardRow[])[], - private readonly palette: Palette, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) - const naturalLabelWidth = Math.max(...labels.map(label => label.length)) - const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => - 1 + naturalLabelWidth + 2 + visibleWidth(value)))) - const cardWidth = Math.min( - Math.max(8, width), - Math.max('Session status'.length + 5, naturalBodyWidth + 4), +function gitBranch(cwd: string): string | undefined { + try { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), ) - const innerWidth = Math.max(1, cardWidth - 4) - const labelWidth = Math.min( - naturalLabelWidth, - Math.max(1, Math.floor(innerWidth / 3)), - ) - const body: string[] = [] - for (const [groupIndex, group] of this.groups.entries()) { - if (groupIndex > 0) body.push('') - for (const [label, value] of group) { - const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') - const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` - const continuation = ' '.repeat(1 + labelWidth + 2) - const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) - const wrapped = wrapTextWithAnsi(value, valueWidth) - for (const [lineIndex, line] of wrapped.entries()) { - body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) - } - } - } - - const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') - const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) - const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` - const lines = [top] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) - } - lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) - return lines + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1_000, + }).trim() + /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ + return branch === '' ? undefined : branch + } catch (_gitUnavailableOrOutsideWorktree) { + return undefined } } -class FooterComponent implements Component { - constructor( - private readonly agent: Agent, - private readonly palette: Palette, - private readonly toolsExpanded: () => boolean, - private readonly tokens: () => SessionTokenTotals, - private readonly cwdFormatter: TuiRuntime['formatCwd'], - private readonly currentModel: () => string | undefined, - private readonly contextPercent: () => number | undefined, - ) {} - - invalidate(): void {} - - render(width: number): string[] { - const totals = this.tokens() - const model = displayText(this.currentModel() ?? 'model unset') - const rate = cacheHitRate(totals) - const cache = rate === undefined ? '' : ` cache ${rate}%` - const formattedCwd = displayText( - this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd), - ) - const left = `${model} ${formattedCwd} ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}` - const contextPercent = this.contextPercent() - const context = contextPercent === undefined ? '' : `${contextPercent}% context ` - const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}` - const leftStyled = this.palette.dim(left) - const available = Math.max(0, width - visibleWidth(left) - 2) - const rightClipped = truncateToWidth(right, available, '') - const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] - } -} - -interface QuestionSelection { - selected: string[] - custom?: string -} - -function renderDialog( - title: string, - body: readonly string[], - width: number, - palette: Palette, -): string[] { - const innerWidth = Math.max(1, width - 4) - const topLabel = ` ${displayText(title)} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [palette.accent(top)] - for (const line of body) { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) - } - lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines -} - -class ModelDialog implements Component { - private readonly list: SelectList - private readonly items: Map - private readonly choices: Map - private readonly efforts: Map - private readonly currentValue: string | undefined - - constructor( - choices: readonly ModelChoice[], - current: AgentLlmTarget | undefined, - maxVisible: number, - private readonly palette: Palette, - done: (selection: ModelDialogSelection) => void, - cancel: () => void, - ) { - this.items = new Map() - this.choices = new Map() - this.efforts = new Map() - this.currentValue = current === undefined ? undefined : targetLabel(current) - for (const choice of choices) { - const value = targetLabel(choice) - const isCurrent = current?.provider === choice.provider && current.model === choice.model - this.choices.set(value, choice) - this.efforts.set( - value, - isCurrent - ? current.reasoningEffort ?? choice.reasoning?.defaultEffort - : choice.reasoning?.defaultEffort, - ) - this.items.set(value, { - value, - label: displayText(value), - description: this.describeChoice(choice, isCurrent), - }) - } - this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) - const currentIndex = current === undefined - ? 0 - : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) - this.list.setSelectedIndex(currentIndex) - this.list.onSelect = (item) => { - const selected = choices.find(choice => targetLabel(choice) === item.value) - /* v8 ignore next -- SelectList only returns values built from `choices`. */ - if (selected === undefined) return - done({ - choice: selected, - reasoningEffort: this.efforts.get(item.value), - }) - } - this.list.onCancel = cancel - } - - private describeChoice(choice: ModelChoice, isCurrent: boolean): string { - const selectedEffort = this.efforts.get(targetLabel(choice)) - const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) - const effortLabel = selectedEffort === undefined - ? choice.reasoning === undefined ? undefined : 'provider default' - : effort?.name ?? selectedEffort - return [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...effortLabel === undefined ? [] : [displayText(effortLabel)], - ...isCurrent ? ['current'] : [], - ].join(' — ') - } - - private cycleReasoningEffort(): void { - const selectedItem = this.list.getSelectedItem() - /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ - if (selectedItem === null) return - const choice = this.choices.get(selectedItem.value) - if (choice?.reasoning === undefined) return - const current = this.efforts.get(selectedItem.value) - const efforts: Array = [ - ...choice.reasoning.defaultEffort === undefined ? [undefined] : [], - ...choice.reasoning.efforts.map(effort => effort.id), - ] - const currentIndex = efforts.indexOf(current) - const next = efforts[(currentIndex + 1) % efforts.length] - this.efforts.set(selectedItem.value, next) - const item = this.items.get(selectedItem.value) - /* v8 ignore next -- items and choices are constructed from the same values. */ - if (item === undefined) return - item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) - } - - invalidate(): void { - this.list.invalidate() - } - - handleInput(data: string): void { - if (matchesKey(data, Key.shift(Key.tab))) { - this.cycleReasoningEffort() - } else { - this.list.handleInput(data) - } - this.invalidate() - } - - render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) - return renderDialog('Select model', [ - ...this.list.render(innerWidth), - '', - this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), - ], width, this.palette) - } -} - -interface ResumeRoute { - provider: string - model: string -} - -interface ResumeCandidate { - record: SessionRecord - title: string - lastActivityAt: number - lastTurn: string - route?: ResumeRoute - goalPhase?: GoalPhase - disabledReason?: string -} - -function resumeTurnLabel(snapshot: SessionLogSnapshot): string { - const event = snapshot.events.findLast(item => item.type === 'turn/end') - if (event === undefined) return 'no completed turn' - const reason = event.data.reason - switch (reason.kind) { - case 'completed': return `turn ${event.data.turn}: completed` - case 'aborted': return `turn ${event.data.turn}: cancelled` - case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` - case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'rejected': return `turn ${event.data.turn}: rejected` - case 'interrupted': return `turn ${event.data.turn}: interrupted` - default: return `turn ${event.data.turn}: unknown result` - } -} - -function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { - const header = snapshot.events.findLast(item => item.type === 'request/header') - if (header?.type === 'request/header') { - return { provider: header.data.header.config.provider, model: header.data.header.config.model } - } - const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') - return assistant?.type === 'assistant/message' - ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } - : undefined -} - -function summarizeResumeCandidate( - record: SessionRecord, - snapshot: SessionLogSnapshot, - currentId: SessionId, - cwd: string | undefined, - availableProviders: ReadonlySet, -): ResumeCandidate { - const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' - const route = resumeRoute(snapshot) - const foldedGoal = foldGoal(snapshot.events).goal - let disabledReason: string | undefined - if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live) disabledReason = 'session is already live in this runtime' - else if (record.header.cwd !== cwd) disabledReason = 'different workspace' - else if (route !== undefined && !availableProviders.has(route.provider)) { - disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` - } - return { - record, - title, - lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, - lastTurn: resumeTurnLabel(snapshot), - ...route === undefined ? {} : { route }, - ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, - ...disabledReason === undefined ? {} : { disabledReason }, - } -} - -/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ -class ResumePicker implements Component, Focusable { - private readonly search = new Input() - private pasteBuffer: string | undefined - private selectedIndex = 0 - private error = '' - focused = false - - constructor( - private readonly candidates: readonly ResumeCandidate[], - private readonly maxVisible: number, - private readonly workspaceLabel: string, - private readonly viewportRows: () => number, - private readonly palette: Palette, - private readonly done: (candidate: ResumeCandidate) => void, - private readonly cancel: () => void, - ) {} - - invalidate(): void { - this.search.invalidate() - } - - private filtered(): ResumeCandidate[] { - const query = this.search.getValue().trim().toLocaleLowerCase() - if (query === '') return [...this.candidates] - return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) - || candidate.record.header.id.toLocaleLowerCase().includes(query)) - } - - private visibleCandidateCount(): number { - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) - return Math.min(this.maxVisible, candidateBudget) - } - - private handleBracketedPaste(data: string): boolean { - const start = data.indexOf(BRACKETED_PASTE_START) - if (this.pasteBuffer === undefined && start < 0) return false - if (this.pasteBuffer === undefined) { - const prefix = data.slice(0, start) - if (prefix !== '') this.handleInput(prefix) - this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) - } else { - this.pasteBuffer += data - } - const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (end < 0) return true - const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) - const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) - this.pasteBuffer = undefined - const previous = this.search.getValue() - this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - if (remaining !== '') this.handleInput(remaining) - this.invalidate() - return true - } - - handleInput(data: string): void { - if (this.handleBracketedPaste(data)) return - const filtered = this.filtered() - if (matchesKey(data, Key.ctrl('c'))) { - this.cancel() - return - } - if (matchesKey(data, Key.escape)) { - if (this.search.getValue() === '') this.cancel() - else { - this.search.setValue('') - this.selectedIndex = 0 - this.error = '' - } - } else if (matchesKey(data, Key.up)) { - this.selectedIndex = filtered.length === 0 - ? 0 - : (this.selectedIndex + filtered.length - 1) % filtered.length - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length - } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) - } else if (matchesKey(data, Key.pageDown)) { - this.selectedIndex = Math.min( - Math.max(0, filtered.length - 1), - this.selectedIndex + this.visibleCandidateCount(), - ) - } else if (matchesKey(data, Key.enter)) { - const selected = filtered[this.selectedIndex] - if (selected === undefined) this.error = 'No session matches this search.' - else if (selected.disabledReason !== undefined) this.error = selected.disabledReason - else this.done(selected) - } else { - const previous = this.search.getValue() - this.search.focused = this.focused - this.search.handleInput(data) - if (this.search.getValue() !== previous) { - this.selectedIndex = 0 - this.error = '' - } - } - this.invalidate() - } - - render(width: number): string[] { - this.search.focused = this.focused - const height = Math.max(1, this.viewportRows()) - const horizontalPadding = width >= 12 ? 2 : 0 - const contentWidth = Math.max(1, width - horizontalPadding * 2) - const indent = ' '.repeat(horizontalPadding) - const filtered = this.filtered() - if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const selected = filtered[this.selectedIndex] - const position = selected === undefined ? 0 : this.selectedIndex + 1 - const lines: string[] = [ - '', - `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, - '', - ] - - const searchInnerWidth = Math.max(1, contentWidth - 4) - lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') - const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') - lines.push( - `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, - `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, - '', - `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, - '', - ) - - const visibleCount = this.visibleCandidateCount() - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(visibleCount / 2), - filtered.length - visibleCount, - )) - const end = Math.min(filtered.length, start + visibleCount) - const push = (line: string): void => { - lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) - } - for (let index = start; index < end; index += 1) { - const candidate = filtered[index] as ResumeCandidate - const active = index === this.selectedIndex - const status = [ - candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live ? 'live' : undefined, - candidate.record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` - push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` - const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) - if (candidate.disabledReason !== undefined) { - push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) - } - } - if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) - if (this.error !== '') { - lines.push('') - push(this.palette.error(displayText(this.error))) - } - - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` - while (lines.length < height - 2) lines.push('') - lines.push(footer, '') - return lines.slice(0, height) - } -} - -class QuestionDialog implements Component, Focusable { - private selectedIndex = 0 - private selected = new Set() - private mode: 'options' | 'custom' - private error = '' - private readonly input = new Input() - private readonly options: NonNullable - focused = false - - constructor( - private readonly question: AskUserQuestionItem, - private readonly position: number, - private readonly total: number, - private readonly unanswered: number, - private readonly maxVisible: number, - private readonly palette: Palette, - private readonly done: (selection: QuestionSelection) => void, - private readonly cancel: () => void, - ) { - this.options = question.options ?? [] - this.mode = this.options.length > 0 ? 'options' : 'custom' - this.input.onSubmit = (value) => { this.submitCustom(value) } - this.input.onEscape = () => { - if (this.options.length > 0) { - this.mode = 'options' - this.error = '' - } else { - this.cancel() - } - } - } - - invalidate(): void { - this.input.invalidate() - } - - handleInput(data: string): void { - this.invalidate() - if (this.mode === 'custom') { - this.input.focused = this.focused - this.input.handleInput(data) - return - } - const options = this.options - if (matchesKey(data, Key.up)) { - this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 - } else if (matchesKey(data, Key.down)) { - this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 - } else if (matchesKey(data, Key.space) && this.question.multiSelect) { - if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) - else this.selected.add(this.selectedIndex) - } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { - this.error = 'Select at least one option, or press Tab for a custom answer.' - return - } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { - this.mode = 'custom' - this.error = '' - } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { - this.cancel() - } - } - - private submitCustom(value: string): void { - const custom = value.trim() - if (custom === '') { - this.error = 'Enter an answer before submitting.' - return - } - this.done({ selected: [], custom }) - } - - render(width: number): string[] { - this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) - const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.muted(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), - ] - const push = (line: string): void => { lines.push(line) } - // Supporting detail (e.g. the full plan under review) renders between the - // question and the answer surface, kept out of option labels. - if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` - push(`${leftStyled}${description}`) - } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) - const hint = this.palette.dim(this.question.multiSelect - ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' - : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) - } - if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) - } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` - }) - } -} - -interface PendingQuestion { - request: AskUserQuestionRequest - index: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void - overlay: TuiOverlaySession | undefined -} - -/** Merge path-only file candidates and optional session snapshots with commands. */ -class ReferenceAutocompleteProvider implements AutocompleteProvider { - constructor( - private readonly base: CombinedAutocompleteProvider, - private readonly files: WorkspaceFileSearch, - private readonly sessions: SessionReferenceService | undefined, - private readonly agent: Agent, - ) {} - - async getSuggestions( - lines: string[], - cursorLine: number, - cursorCol: number, - options: { signal: AbortSignal; force?: boolean }, - ): Promise { - const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options) - const currentLine = lines[cursorLine] - /* v8 ignore next -- Editor always supplies its current state line. */ - if (currentLine === undefined) return basePromise - const token = activeAtToken(currentLine, cursorCol) - if (token === undefined) { - this.files.invalidate() - return basePromise - } - const filePromise = this.files.list(token.query, options.signal).catch(() => []) - const sessionPromise = this.sessions === undefined || token.quoted - ? Promise.resolve([]) - : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) - const [base, fileCandidates, sessionCandidates] = await Promise.all([ - basePromise, - filePromise, - sessionPromise, - ]) - if (options.signal.aborted) return base - const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { - const value = formatFileMention(candidate, token.quoted) - if (value === undefined) return [] - const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) - const directory = candidate.kind === 'directory' - return [{ - value, - label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, - description: displayInlineText(candidate.path), - }] - }) - const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { - const mentionLabel = displayInlineText(candidate.label) - const sessionId = displayInlineText(candidate.sessionId) - const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) - const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` - return { - value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }), - label: `Session · ${mentionLabel}`, - description, - } - }) - const items = [...fileItems, ...sessionItems] - if (items.length === 0) return base - return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } - } - - applyCompletion( - lines: string[], - cursorLine: number, - cursorCol: number, - item: AutocompleteItem, - prefix: string, - ): { lines: string[]; cursorLine: number; cursorCol: number } { - return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix) - } - - shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { - return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol) - } -} - -/** Lifecycle handle for a mounted interactive terminal channel. */ -export interface TuiController { - /** Stop rendering, restore the terminal, and reject pending questions. */ - dispose(): Promise -} - -/** Prefix that marks an editor submission as a manual skill invocation. */ -const SKILL_COMMAND_PREFIX = '/skill:' - -/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ -interface ParsedSkillCommand { - /** Skill name typed after `/skill:`, up to the first space. */ - name: string - /** Trimmed text after the name; empty when none was typed. */ - instructions: string -} - -/** - * Split a `/skill: [instructions]` submission into its name and trailing instructions. - * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. - * @returns the skill name and any trailing instructions. - */ -function parseSkillCommand(text: string): ParsedSkillCommand { - const rest = text.slice(SKILL_COMMAND_PREFIX.length) - const spaceIndex = rest.indexOf(' ') - if (spaceIndex === -1) return { name: rest, instructions: '' } - return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } -} - -/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ -function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { - if (base === undefined) return undefined - switch (base.kind) { - case 'directory': - return `References in this skill are relative to ${base.path}.` - case 'url': - return `References in this skill are relative to ${base.url}.` - case 'opaque': - return base.description - default: - return assertNever(base, 'SkillResourceBase.kind') - } -} - -/** - * Render a manually invoked skill into the model-visible user-message text. The - * `` block carries the body and, when the provider supplies one, its - * resource base; the trimmed `instructions` follow the block as the user's - * request for this turn. The name is registry-validated kebab-case - * ({@link SkillService} rejects any other) and the resource base is trusted - * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` - * result, which escapes for a tool channel — this user turn is assembled raw. - * @param skill - the loaded skill definition. - * @param instructions - trimmed text typed after `/skill:`; empty when absent. - * @returns the user-message text delivered to the agent. - */ -export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { - const lines = [``] - const reference = skillResourceReference(skill.resourceBase) - if (reference !== undefined) lines.push(reference, '') - lines.push(skill.content, '') - const block = lines.join('\n') - return instructions === '' ? block : `${block}\n\n${instructions}` -} - function activeSurfaceSeqs(session: Session): Set { return new Set(session.surface.nodes) } @@ -1939,6 +376,12 @@ function activeToolCallIds(session: Session, active: ReadonlySet): Set renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) + const initialInputPrompt = renderInputPrompt() + const editor = new HintEditor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), - } satisfies EditorTheme, { paddingX: 1 }) + } satisfies EditorTheme, { + paddingX: 1, + frame: 'none', + prompt: { + first: initialInputPrompt, + continuation: ' '.repeat(visibleWidth(initialInputPrompt)), + }, + }) + editor.hintPrefix = initialInputPrompt const todo = new TodoComponent(palette) let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined + let completedStreaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined + let fadingStatus: FadingStatus | undefined // Steering messages queued during the running turn (`agent/inbox/enqueue` // with `info.steering`) that the loop has not yet drained, shown as a badge on // the status line. Each entry is the queued message's serialized source: a @@ -2025,27 +480,82 @@ export function createTuiChat( agent, () => sessionTitle ?? config.welcome, palette, - resolved.color && resolved.truecolor, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), + resolved.theme.color && resolved.theme.truecolor, ) - const footer = new FooterComponent( - agent, - palette, - () => toolsExpanded, - () => tokens, - runtime.formatCwd, - () => target.current === undefined ? undefined : compactTargetLabel(target.current), - () => contextWindow === undefined - ? undefined - : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), + const formattedCwd = displayText(runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd)) + const branch = runtime.gitBranch?.(cwd) ?? gitBranch(cwd) + const promptValues: TuiPromptValueHandle[] = [ + ctx.tuiPrompt.register('cwd', palette.bold(palette.accent(formattedCwd))), + ctx.tuiPrompt.register('git/worktree', branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)), + ctx.tuiPrompt.register('token_meter/cache_hit_rate'), + ctx.tuiPrompt.register('model'), + ctx.tuiPrompt.register('context'), + ctx.tuiPrompt.register('timing'), + ctx.tuiPrompt.register('symbol', palette.bold(palette.accent('dsh'))), + ctx.tuiPrompt.register('indicator', palette.muted('> ')), + ] + const [cwdValue, gitValue, tokenValue, modelValue, contextValue, timingValue, symbolValue, indicatorValue] = promptValues + /* v8 ignore next -- the fixed built-in registration list always supplies each handle. */ + if (cwdValue === undefined || gitValue === undefined || tokenValue === undefined || modelValue === undefined + || contextValue === undefined || timingValue === undefined || symbolValue === undefined || indicatorValue === undefined) { + throw new Error('TUI prompt built-ins failed to initialize') + } + const updatePromptValues = (): void => { + cwdValue.set(palette.bold(palette.accent(formattedCwd))) + gitValue.set(branch === undefined ? undefined : palette.muted(` (${displayText(branch)})`)) + const rate = cacheHitRate(tokens) + const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` + modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) + tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) + contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted( + `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, + )}`) + const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.length) + timingValue.set(queued === undefined ? undefined : palette.dim(queued)) + symbolValue.set(palette.bold(palette.accent('dsh'))) + // `${indicator}` owns the caret column and its trailing gap before the + // cursor. The phase glyph replaces the `>` caret in place — same width + // every frame — fading in as a turn starts, throbbing while it runs, and + // fading out after it ends before the plain `>` returns. Only the gray + // brightness changes, so the cursor never shifts. + const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined) + // Remember the live phase glyph so the fade-out shows it, not the ttft + // fallback the derivation returns once the closing turn's step has ended. + if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph + // The fade envelope gates appear/disappear; the running throb breathes the + // glyph the whole turn. Truecolor opacity is envelope × throb; the + // non-truecolor fallback keys visibility off the envelope alone, so the + // throb never blinks it. `envelope` clamps to [0, 1]. + const envelope = runningStatus !== undefined && runningGlyph !== undefined + ? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) } + : fadingStatus !== undefined + ? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) } + : undefined + const caret = envelope === undefined + ? palette.muted('>') + : fadeGlyph( + envelope.glyph, + palette, + resolved.theme.color, + resolved.theme.color && resolved.theme.truecolor, + envelope.level * pulseLevel(now()), + envelope.level >= 0.5, + ) + indicatorValue.set(`${caret}${palette.muted(' ')}`) + } + updatePromptValues() + const promptContext = new PromptContextComponent( + parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), + parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), + valueName => ctx.tuiPrompt.get(valueName), ) ui.addChild(header) ui.addChild(chat) - ui.addChild(statusContainer) + ui.addChild(new Spacer(1)) todoContainer.addChild(todo) ui.addChild(todoContainer) + ui.addChild(promptContext) ui.addChild(editor) - ui.addChild(footer) ui.setFocus(editor) const updateTerminalTitle = (): void => { runtime.terminal.setTitle(displayText( @@ -2055,14 +565,23 @@ export function createTuiChat( updateTerminalTitle() const requestRender = (): void => { - footer.invalidate() + if (disposed) return + updatePromptValues() + const inputPrompt = renderInputPrompt() + editor.setPrompt({ first: inputPrompt, continuation: ' '.repeat(visibleWidth(inputPrompt)) }) + editor.hintPrefix = inputPrompt + promptContext.invalidate() ui.requestRender() } + // A prompt value that changes on its own schedule (e.g. a plugin-owned + // `${custom}` fragment) redraws through the registry's coalesced notification; + // built-ins are already covered by the state-change callers of requestRender. + const disposePromptChanges = ctx.tuiPrompt.subscribe(requestRender) const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted chat.addChild(new Spacer(1)) - chat.addChild(new Text(color(displayText(message)), 1, 0)) + chat.addChild(new Text(color(displayText(message)), 0, 0)) requestRender() } @@ -2164,7 +683,7 @@ export function createTuiChat( target.current, resolved.maxModelOptions, palette, - (selection) => { + (selection: ModelDialogSelection) => { void session.close() selectModel(selection.choice, { effort: selection.reasoningEffort }) }, @@ -2228,58 +747,61 @@ export function createTuiChat( }) } + const renderStatus = (): void => { + streaming?.invalidate() + requestRender() + } + + /** Stop the running and fade-out timers and drop both states at once. */ const clearStatus = (): void => { if (runningStatus !== undefined) { clearInterval(runningStatus.timer) - runningStatus.loader.stop() runningStatus = undefined } - statusContainer.clear() + if (fadingStatus !== undefined) { + clearInterval(fadingStatus.timer) + fadingStatus = undefined + } runtime.terminal.setProgress(false) } - // Refresh the status line's elapsed timers and queued badge from the - // controller's phase and the current steering count. - const renderStatus = (running: RunningStatus): void => { - const at = now() - running.loader.setMessage( - formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length), - ) - } - - // Move to a derived phase, resetting the phase timer on a genuine change and - // the step timer when a new step begins; ignored unless a turn is running. - const enterPhase = (phase: TurnPhase, resetStep: boolean): void => { - const running = runningStatus - if (running === undefined) return - const at = now() - if (resetStep) running.stepStartedAt = at - if (phase !== running.phase || resetStep) running.phaseStartedAt = at - running.phase = phase - renderStatus(running) + /** + * On the running → non-running edge, hand the last rendered glyph to a + * fade-out that re-renders until it settles on the `>` caret, then stops its + * own timer. A hard clear (teardown) skips this via {@link clearStatus}. + */ + const beginFadeOut = (glyph: string): void => { + clearStatus() + const fading: FadingStatus = { + glyph, + endedAt: now(), + timer: setInterval(() => { + if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus() + renderStatus() + }, STATUS_ANIMATION_INTERVAL_MS), + } + fadingStatus = fading } const setStatus = (status: AgentStatus): void => { - // A running→running rebuild (a mid-turn palette swap re-derives the border) - // carries the derived phase and both elapsed baselines across; only a fresh - // idle→running turn starts at `waiting`. - const prior = runningStatus - clearStatus() + const priorTurn = runningStatus?.turn + const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined + if (status === 'running') clearStatus() + else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph) + else clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) + editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined if (status === 'running') { - const at = now() - const phase = prior?.phase ?? 'waiting' - const phaseStartedAt = prior?.phaseStartedAt ?? at - const stepStartedAt = prior?.stepStartedAt ?? at - const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length) - const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) - statusContainer.addChild(loader) + const turn = priorTurn ?? openTurn(agent.session.events) const running: RunningStatus = { - loader, - phase, - phaseStartedAt, - stepStartedAt, - timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS), + turn, + startedAt: now(), + // Seed with the current phase (ttft before the first step opens) so the + // fade-out always has a glyph, even for a turn that ends before a render. + lastGlyph: TIMING_BUCKET_GLYPHS[openStepPhase(agent.session.events) ?? 'ttft'], + // Refresh every tick so the fading prompt phase glyph animates even + // before the first token, when no streaming component exists yet. + timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS), } runningStatus = running runtime.terminal.setProgress(true) @@ -2287,35 +809,8 @@ export function createTuiChat( requestRender() } - // Refresh the running status line's queued-steering badge from the current - // count; a no-op when idle because the controller only exists while running. const refreshStatus = (): void => { - if (runningStatus !== undefined) renderStatus(runningStatus) - requestRender() - } - - // Derive the status-line phase from live session lifecycle events. The event - // map is merge-extensible, so unhandled types fall through the default. - const advanceTurnPhase = (event: SessionEvent): void => { - switch (event.type) { - case 'step/start': - enterPhase('waiting', true) - break - case 'assistant/chunk': { - const chunk = event.data.chunk - if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { - enterPhase('thinking', false) - } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { - enterPhase('responding', false) - } - break - } - case 'tool/call': - enterPhase('executing', false) - break - default: - break - } + renderStatus() } const parsedTool = (event: Extract): ToolCardComponent => { @@ -2326,6 +821,7 @@ export function createTuiChat( ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, palette, + mdTheme, ) card.setExpanded(toolsExpanded) toolCards.set(event.data.callId, card) @@ -2333,15 +829,62 @@ export function createTuiChat( return card } - const clearStreaming = (): void => { + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { + if (current === undefined) return + for (const child of [current, current.timing]) { + const index = chat.children.indexOf(child) + /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ + if (index >= 0) chat.children.splice(index, 1) + } + } + + /** + * Move the running step's timing footer to the tail of the chat so it trails + * the tool cards the step just appended. A completed footer (its step ended, + * so `streaming` is cleared) stays pinned where it is. + */ + const trailStreamingTiming = (): void => { + /* v8 ignore next -- every replayed tool event follows its step/start, so an open step always owns an attached footer here. */ if (streaming === undefined) return - const index = chat.children.indexOf(streaming) - /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ - if (index >= 0) chat.children.splice(index, 1) + const footer = streaming.timing + const index = chat.children.indexOf(footer) + /* v8 ignore next -- the open step's footer is attached to the chat whenever a tool event of that step renders. */ + if (index < 0) return + chat.children.splice(index, 1) + chat.addChild(footer) + } + + const clearStreaming = (): void => { + removeStreaming(streaming) streaming = undefined } - const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { + const retractFailedStreaming = (): void => { + removeStreaming(streaming ?? completedStreaming) + streaming = undefined + completedStreaming = undefined + } + + const startAssistantStep = (position: StepPosition): void => { + streaming = new StreamingAssistantComponent( + position, + () => agent.session.events, + now, + showReasoning, + palette, + mdTheme, + ) + chat.addChild(streaming) + chat.addChild(streaming.timing) + } + + const renderEvent = ( + event: SessionEvent, + options: { + addHistory: boolean + renderChunks: boolean + }, + ): void => { switch (event.type) { case 'user/message': { // Injected context (plugin/goal source) renders as a dim context card, @@ -2352,18 +895,29 @@ export function createTuiChat( const references = sessionReferenceCard(event.data.meta) if (references !== undefined) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) break } - const text = displayText(contentText(event.data.content).trim()) + const text = contentText(event.data.content).trim() + /* v8 ignore next -- context events with empty content are rejected by their owning producers. */ if (text) { // The tui type view lacks plugin-augmented source kinds (e.g. goal), // so read the display label without narrowing on `kind`. const labelled = source as { kind: string; plugin?: string } + /* v8 ignore next -- current plugin-augmented context sources always carry their display label. */ const label = labelled.plugin ?? labelled.kind + const xml = renderUnknownXml( + text, + resolved.maxToolOutputLines, + true, + displayText, + value => palette.muted(value), + /* v8 ignore next -- expanded context XML never asks renderUnknownXml for a collapsed summary. */ + () => '', + ) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0)) - chat.addChild(new Text(palette.muted(text), 1, 0)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 0, 0)) + chat.addChild(new Text(xml?.join('\n') ?? palette.muted(displayText(text)), 0, 0)) } break } @@ -2375,7 +929,7 @@ export function createTuiChat( } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) } break } @@ -2387,30 +941,27 @@ export function createTuiChat( } for (const references of promptReferenceCards(event)) { chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 0, 0)) } break } + case 'prompt/blocked': appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') break + case 'step/start': + startAssistantStep(event.data) + break case 'assistant/chunk': - if (options.renderChunks) { - if (streaming === undefined) { - streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme) - chat.addChild(streaming) - } - streaming.update(event.data.chunk) - } + if (options.renderChunks) streaming?.update(event.data.chunk) break - case 'assistant/message': { - clearStreaming() - const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) - if (component.children.length > 0) chat.addChild(component) + case 'assistant/message': + completedStreaming = undefined + if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) + streaming?.settle(event.data.content) break - } case 'llm/retry': { - clearStreaming() + retractFailedStreaming() appendNotice( `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', @@ -2420,17 +971,19 @@ export function createTuiChat( case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) + trailStreamingTiming() break case 'tool/result': { let card = toolCards.get(event.data.callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette) + card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) chat.addChild(new Spacer(1)) chat.addChild(card) allToolCards.add(card) } card.updateResult(event.data) toolCards.delete(event.data.callId) + trailStreamingTiming() break } case 'todo/write': @@ -2441,24 +994,50 @@ export function createTuiChat( header.invalidate() updateTerminalTitle() break - case 'turn/end': + case 'step/end': + if (streaming === undefined) startAssistantStep(event.data) + streaming?.complete(event.time) + completedStreaming = streaming + streaming = undefined + break + // Every turn/end kind presents why the agent stopped: `completed` is + // presented by the settled assistant message and its Completed timing + // header; every other kind appends an explicit notice. + case 'turn/end': { clearStreaming() - if (event.data.reason.kind === 'error') { - const key = `${event.data.turn}:${event.data.reason.step}` - const message = 'failure' in event.data.reason - ? event.data.reason.failure.message - : event.data.reason.message - if (!liveErrors.delete(key)) appendNotice(message, 'error') - } else if (event.data.reason.kind === 'aborted') { - appendNotice('Turn cancelled.', 'warning') - } else if (event.data.reason.kind === 'max-tokens') { - appendNotice('The model reached its output-token limit.', 'warning') - } else if (event.data.reason.kind === 'rejected') { - appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning') - } else if (event.data.reason.kind === 'interrupted') { - appendNotice('The previous process ended during this turn.', 'warning') + const reason = event.data.reason + switch (reason.kind) { + case 'completed': + break + case 'error': { + const key = `${event.data.turn}:${reason.step}` + const message = 'failure' in reason ? reason.failure.message : reason.message + if (!liveErrors.delete(key)) appendNotice(message, 'error') + break + } + case 'aborted': + appendNotice('Turn cancelled.', 'warning') + break + case 'max-tokens': + appendNotice('The model reached its output-token limit.', 'warning') + break + case 'rejected': + appendNotice(`Turn rejected: ${reason.reason}`, 'warning') + break + case 'disposed': + appendNotice('Turn stopped: the agent was disposed.', 'warning') + break + case 'interrupted': + appendNotice('The previous process ended during this turn.', 'warning') + break + default: + // TurnEndReasonMap is merge-extensible: a plugin-added outcome + // still names why the agent stopped rather than ending silently. + appendNotice(`Turn ended: ${(reason as { kind: string }).kind}.`, 'warning') + break } break + } default: break } @@ -2667,7 +1246,7 @@ export function createTuiChat( const applyColorScheme = (scheme: TerminalColorScheme): void => { if (scheme === currentScheme) return currentScheme = scheme - Object.assign(palette, createPalette(resolved.color, scheme)) + Object.assign(palette, createPalette(resolved.theme.color, scheme)) Object.assign(mdTheme, markdownTheme(palette)) // `setStatus` below re-derives `editor.borderColor` from the new palette. rebuildTranscript(false) @@ -2698,10 +1277,12 @@ export function createTuiChat( showReasoning = !showReasoning const activeStreaming = streaming rebuildTranscript(false) + /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) chat.addChild(activeStreaming) + chat.addChild(activeStreaming.timing) } appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } @@ -2712,7 +1293,7 @@ export function createTuiChat( return `/${command.name}${input} — ${command.description}` }) chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) + chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 0, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', @@ -2720,11 +1301,17 @@ export function createTuiChat( '', ...commandLines, '/skill: [instructions] — load a skill into the conversation', - ].map(line => palette.muted(line)).join('\n'), 1, 0)) + ].map(line => palette.muted(line)).join('\n'), 0, 0)) requestRender() } - const showStatus = (): void => { + const showStatus = async (signal: AbortSignal): Promise => { + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) + /* v8 ignore next -- disposal during the awaited assembly is covered by command-owner teardown tests. */ + if (disposed) return + /* v8 ignore next -- SystemPrompt always emits at least its required base section. */ + const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)' + const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)' const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) @@ -2774,6 +1361,12 @@ export function createTuiChat( const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('System prompt')), 0, 0)) + chat.addChild(new Text(systemPrompt, 0, 0)) + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('Registered tools')), 0, 0)) + chat.addChild(new Text(registeredTools, 0, 0)) requestRender() } @@ -2809,10 +1402,15 @@ export function createTuiChat( service.list({ cwd, signal: skillAbort.signal }).then( (summaries) => { if (disposed || summaries.length === 0) return + // The argument-hint slot shows in the menu but is never inserted on + // selection, so it carries the skill's scope instead of an + // instructions placeholder. `SkillSource` is open-ended; every + // non-project source (user, custom, bundled, runtime, …) collapses + // to `(user)`. skillCommands = summaries.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, - argumentHint: '[instructions]', + argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', })) refreshCommandAutocomplete() requestRender() @@ -2875,13 +1473,22 @@ export function createTuiChat( }) commandCtx.commands.register({ name: 'status', - description: 'Show detailed session diagnostics', - handler: () => { showStatus(); return { kind: 'success' } }, + description: 'Show session diagnostics, system prompt, and registered tools', + handler: async ({ signal }) => { await showStatus(signal); return { kind: 'success' } }, }) + const exitHandler = (): CommandResult => { + requestExit() + return { kind: 'success' } + } commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', - handler: () => { requestExit(); return { kind: 'success' } }, + handler: exitHandler, + }) + commandCtx.commands.register({ + name: 'quit', + description: 'Exit after the active turn reaches idle', + handler: exitHandler, }) }) const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => { @@ -3165,9 +1772,9 @@ export function createTuiChat( if (text.startsWith(SKILL_COMMAND_PREFIX)) { editor.addToHistory(text) editor.setText('') - const { name, instructions } = parseSkillCommand(text) - if (name === '') appendNotice('Usage: /skill: [instructions]', 'warning') - else invokeSkill(name, instructions) + const { name: skillName, instructions } = parseSkillCommand(text) + if (skillName === '') appendNotice('Usage: /skill: [instructions]', 'warning') + else invokeSkill(skillName, instructions) return } if (value.startsWith('/')) { @@ -3262,7 +1869,8 @@ export function createTuiChat( if (session !== agent.session) return if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) - advanceTurnPhase(event) + if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn + if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined if (event.type === 'steering/message') { // A queued steering message reached the model as it drained; drop its // entry from the badge. Matching by source keeps a loop-authored @@ -3271,7 +1879,7 @@ export function createTuiChat( const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) if (drained >= 0) { pendingSteering.splice(drained, 1) - refreshStatus() + if (runningStatus !== undefined) refreshStatus() } } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { @@ -3284,7 +1892,7 @@ export function createTuiChat( const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => { if (subject !== agent || !info.steering) return pendingSteering.push(JSON.stringify(info.source)) - refreshStatus() + if (runningStatus !== undefined) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return @@ -3312,6 +1920,8 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposePromptChanges() + for (const value of promptValues) value.dispose() stopBannerReveal() disposeSessionEvents() disposeQueued() @@ -3352,6 +1962,7 @@ export function createTuiChat( rebuildTranscript(true) const restoredGoal = foldGoal(agent.session.events).goal + /* v8 ignore next -- goal replay coverage lives with the goal seam; the TUI only formats its startup notice. */ if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { appendNotice( `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` @@ -3444,10 +2055,10 @@ export function apply(ctx: Context, config: Config): void { throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes') } // Truecolor is a terminal capability, so detect it here at the process - // boundary from COLORTERM; an explicit `truecolor` config value still wins. - const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + // boundary from COLORTERM; an explicit theme value still wins. + const truecolor = config.theme?.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') const resumeHost = ctx.get('tuiResumeHost') - mountTui(ctx, Object.assign({}, config, { truecolor }), { + mountTui(ctx, Object.assign({}, config, { theme: Object.assign({}, config.theme, { truecolor }) }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, diff --git a/packages/ui/tui/src/prompt.ts b/packages/ui/tui/src/prompt.ts new file mode 100644 index 0000000000..db80bfb1e3 --- /dev/null +++ b/packages/ui/tui/src/prompt.ts @@ -0,0 +1,217 @@ +/** + * Mutable terminal-prompt value registry consumed by the TUI template renderer. + * Values are trusted presentation fragments and may contain ANSI control sequences. + * @module @deepseek-ai/dsh-tui/prompt + */ + +import { Context, Service } from 'cordis' +import { errorChain } from '@deepseek-ai/dsh-llm' + +export const name = 'tui-prompt' + +const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u + +/** Handle owned by one prompt-value registration. */ +export interface TuiPromptValueHandle { + /** + * Replace the current fragment and schedule a coalesced change notification + * so the owning renderer redraws. Setting the current value again is a no-op. + * @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable. + */ + set(value: string | undefined): void + + /** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */ + dispose(): void +} + +interface RegisteredValue { + value: string | undefined +} + +declare module 'cordis' { + interface Context { + tuiPrompt: TuiPromptService + } +} + +/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */ +export type TuiPromptUnsubscribe = () => void + +/** One literal or variable token in a parsed TUI prompt template. */ +export type TuiPromptTemplateToken = + | { readonly kind: 'literal'; readonly value: string } + | { readonly kind: 'value'; readonly name: string } + +/** + * Parse a prompt template into immutable literal and value tokens. + * @param template - Text containing `${name}` references. + * @returns Tokens consumed by {@link renderTuiPromptTemplate}. + */ +export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] { + const tokens: TuiPromptTemplateToken[] = [] + const pattern = /\$\{([^}]*)\}/gu + let offset = 0 + for (const match of template.matchAll(pattern)) { + const index = match.index + const name = match[1] + /* v8 ignore next -- the sole capture always exists when this pattern matches. */ + if (name === undefined) continue + if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) })) + tokens.push(Object.freeze({ kind: 'value', name })) + offset = index + match[0].length + } + if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) })) + return Object.freeze(tokens) +} + +/** + * Interpolate one parsed prompt while removing horizontal separators adjacent + * only to unavailable values. + * @param tokens - Parsed template tokens. + * @param resolve - Current value lookup. + * @returns ANSI-capable rendered prompt text. + */ +export function renderTuiPromptTemplate( + tokens: readonly TuiPromptTemplateToken[], + resolve: (name: string) => string | undefined, +): string { + const rendered: string[] = [] + let omitLeadingWhitespace = false + for (const token of tokens) { + if (token.kind === 'value') { + const value = resolve(token.name) + if (value === undefined) { + omitLeadingWhitespace = true + } else { + rendered.push(value) + omitLeadingWhitespace = false + } + continue + } + rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value) + omitLeadingWhitespace = false + } + return rendered.join('') +} + +/** + * Context-global mutable values interpolated by TUI theme prompt templates. + * A registration, mutation, or disposal schedules one coalesced notification to + * the renderer subscribed with {@link TuiPromptService.subscribe}, so a value + * that changes on its own schedule (not only in response to a UI event) still + * redraws. Notification is a direct in-service callback, not a Cordis event. + */ +export class TuiPromptService extends Service { + private readonly values = new Map() + // Per-subscription record identity, not callback identity: two fibers may + // subscribe the same function, and disposing one must not remove the other's. + private readonly listeners = new Set<{ readonly listener: () => unknown }>() + private notificationQueued = false + + constructor(ctx: Context) { + super(ctx, 'tuiPrompt') + } + + /** + * Register one globally unique template value under the calling Cordis effect. + * @param name - Lowercase slash-separated template name. + * @param initialValue - Initial trusted ANSI-capable fragment. + * @returns A mutable handle whose disposal unregisters the name. + */ + register(name: string, initialValue?: string): TuiPromptValueHandle { + if (!VALUE_NAME.test(name)) { + throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`) + } + if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`) + + const registered: RegisteredValue = { value: initialValue } + let active = true + const effectDisposer = this.ctx.effect(() => { + this.values.set(name, registered) + this.scheduleChange() + // Cordis runs this cleanup at most once per effect, and deleting an + // absent key is a no-op, so no re-entrancy guard is needed here; `active` + // exists only to reject a late {@link TuiPromptValueHandle.set}. + return () => { + active = false + this.values.delete(name) + this.scheduleChange() + } + }, `tuiPrompt.register(${name})`) + + return Object.freeze({ + set: (value: string | undefined): void => { + if (!active) throw new Error(`TUI prompt value "${name}" is disposed`) + if (registered.value === value) return + registered.value = value + this.scheduleChange() + }, + dispose: (): void => { void effectDisposer() }, + }) + } + + /** + * Read a registered fragment without evaluating plugin code. + * @param name - Exact registered template name. + * @returns The current fragment, or `undefined` when unknown or unavailable. + */ + get(name: string): string | undefined { + return this.values.get(name)?.value + } + + /** + * Observe registration and value changes. The listener runs after a coalesced + * microtask following any burst of mutations; the renderer re-reads current + * values on that callback. The subscription is owned by the calling Cordis + * effect, so it is removed when the subscriber's fiber disposes; the returned + * disposer removes it early. Listener failures are contained — a synchronous + * throw or a rejected returned promise cannot starve the other observers. + * @param listener - Invoked once per coalesced change burst. Delivery does + * not wait on a returned promise; its rejection is only observed and logged, + * never left unhandled, so an async listener cannot order later observers. + * @returns A disposer that removes the subscription. + */ + subscribe(listener: () => unknown): TuiPromptUnsubscribe { + const record = { listener } + const disposeEffect = this.ctx.effect(() => { + this.listeners.add(record) + return () => { this.listeners.delete(record) } + }, 'tuiPrompt.subscribe') + return () => { void disposeEffect() } + } + + /** Coalesce mutation bursts into one notification while containing each observer. */ + private scheduleChange(): void { + if (this.notificationQueued) return + this.notificationQueued = true + queueMicrotask(() => { + this.notificationQueued = false + // Snapshot so a listener may subscribe/unsubscribe during delivery, but + // re-check liveness: a listener that synchronously unsubscribes another + // observer earlier in the same burst must silence it now, keeping the + // subscription set authoritative during reentrant notification. + for (const record of [...this.listeners]) { + if (this.listeners.has(record)) this.notifyOne(record.listener) + } + }) + } + + /** Deliver one change notification, containing a synchronous throw or a rejected promise. */ + private notifyOne(listener: () => unknown): void { + let returned: unknown + try { + returned = listener() + } catch (error: unknown) { + // errorChain never throws, even on a hostile toString/getter, so the + // notification microtask can never escape to starve later observers. + this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`) + return + } + // A listener may be async; contain a rejected promise the same as a throw. + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`) + }) + } +} + +export default TuiPromptService diff --git a/packages/ui/tui/src/session/timing.ts b/packages/ui/tui/src/session/timing.ts new file mode 100644 index 0000000000..a85097cb76 --- /dev/null +++ b/packages/ui/tui/src/session/timing.ts @@ -0,0 +1,357 @@ +/** + * Per-step timing model and running-status glyph animation for the terminal + * front door. Timing buckets are replayed from the session event stream; the + * running glyph fades in on turn start, throbs while the turn runs, and fades + * out on turn end. + * @module @deepseek-ai/dsh-tui/session/timing + */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Palette } from '../components/theme.ts' + +/** + * Render cadence of the running prompt while active, and while the glyph fades + * out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly; + * the same tick keeps the elapsed-time text (0.1 s resolution) current. Only + * changed terminal cells are re-emitted, so the faster tick stays cheap. + */ +export const STATUS_ANIMATION_INTERVAL_MS = 50 + +/** + * Milliseconds over which the running glyph fades in when a turn starts and + * fades out after it ends. The fade is an envelope over the running pulse: + * inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}). + */ +export const STATUS_FADE_MS = 300 + +/** Milliseconds for one full brightness throb of the running glyph. */ +export const STATUS_PULSE_PERIOD_MS = 1400 + +/** + * Brightness floor of the running throb, as a fraction of the settled gray. At + * 0 the pulse swells from fully invisible (a blank glyph column, see + * {@link STATUS_FADE_MIN_OPACITY}) up to full and back, so the dimmest point of + * each breath truly disappears rather than lingering as a faint mark. + */ +export const STATUS_PULSE_FLOOR = 0 + +/** + * Opacity below which the truecolor running glyph is hidden entirely (a blank + * column) instead of painted as a near-background gray, so the trough of the + * pulse reads as invisible. The fixed glyph width is preserved by the blank. + */ +export const STATUS_FADE_MIN_OPACITY = 0.12 + +/** + * Muted-gray foreground the truecolor running glyph fades through, from the + * near-background trough (opacity 0) to the settled dim gray (opacity 1). Same + * hue-free gray as the idle caret, so the glyph reads as the caret dimly + * appearing rather than a colored indicator. Foreground-only, matching the + * brand gradient, so it stays legible on any terminal background. + */ +const STATUS_FADE_GRAY = { + trough: [43, 43, 43], + settled: [136, 136, 136], +} as const + +/** The active phase of a running step, one bucket of accumulated wall time. */ +export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools' + +/** Turn/step coordinates of one assistant step. */ +export type StepPosition = { turn: number; step: number } + +/** Accumulated wall time per phase for one step or session slice. */ +export interface TimingTotals { + ttft: number + thinking: number + responding: number + tools: number +} + +interface TimingState { + totals: TimingTotals + active: { bucket: TimingBucket; since: number } | undefined +} + +const TIMING_BUCKET_LABELS: Record = { + ttft: 'Model wait', + thinking: 'Thinking', + responding: 'Response', + tools: 'Tools', +} + +const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools'] + +function emptyTimingTotals(): TimingTotals { + return { ttft: 0, thinking: 0, responding: 0, tools: 0 } +} + +function timingState(startedAt?: number): TimingState { + return { + totals: emptyTimingTotals(), + /* v8 ignore next -- production timing state always begins at a logged step timestamp. */ + active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt }, + } +} + +function sameStep(event: SessionEvent, position: StepPosition): boolean { + return typeof event.data === 'object' + && 'turn' in event.data && 'step' in event.data + && event.data.turn === position.turn && event.data.step === position.step +} + +function closeTimingBucket(state: TimingState, at: number): void { + if (state.active === undefined) return + state.totals[state.active.bucket] += Math.max(0, at - state.active.since) + state.active = undefined +} + +function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void { + if (state.active?.bucket === bucket) return + closeTimingBucket(state, at) + if (bucket !== undefined) state.active = { bucket, since: at } +} + +function advanceStepTiming( + state: TimingState, + event: Extract, +): void { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time) + if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) { + enterTimingBucket(state, 'thinking', event.time) + } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) { + enterTimingBucket(state, 'responding', event.time) + } + } else if (event.type === 'tool/call') { + enterTimingBucket(state, 'tools', event.time) + } else { + closeTimingBucket(state, event.time) + } +} + +function timingTotalsAt(state: TimingState, at?: number): TimingTotals { + const totals = { ...state.totals } + if (state.active !== undefined && at !== undefined) { + totals[state.active.bucket] += Math.max(0, at - state.active.since) + } + return totals +} + +/** + * Replay one step's accumulated per-phase timing up to clock `at`. + * @param events - Session events to replay. + * @param position - Turn/step coordinates of the step. + * @param at - Render clock to accumulate the open bucket up to. + * @returns The step's per-phase totals. + */ +export function stepTimingAt( + events: readonly SessionEvent[], + position: StepPosition, + at: number, +): TimingTotals { + const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position)) + if (startIndex < 0) return emptyTimingTotals() + const start = events[startIndex] as Extract + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if (event.time > at) break + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + if (event.type === 'step/end') break + } + } + return timingTotalsAt(state, at) +} + +/** + * The turn index of the currently open turn, or `undefined` when none is open. + * @param events - Session events to scan from the tail. + * @returns The open turn index, or `undefined`. + */ +export function openTurn(events: readonly SessionEvent[]): number | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'turn/end') return undefined + if (event.type === 'turn/start') return event.data.turn + } + return undefined +} + +/** + * Phase-specific status glyph, keyed by the running step's active timing bucket. + * `ttft` is the pre-first-token wait a running turn falls back to between steps. + */ +export const TIMING_BUCKET_GLYPHS: Record = { + ttft: '◍', + thinking: '✻', + responding: '●', + tools: '⚙', +} + +/** + * Derive the currently open step's active timing bucket, or `undefined` when no + * step is open. The open step is the last `step/start` with no later matching + * `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}. + * @param events - Session events to scan. + * @returns The open step's active bucket, or `undefined`. + */ +export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined { + let startIndex = -1 + let start: Extract | undefined + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'step/end') return undefined + if (event.type === 'step/start') { + startIndex = index + start = event + break + } + if (event.type === 'turn/end') return undefined + } + if (start === undefined) return undefined + const position = start.data + const state = timingState(start.time) + for (let index = startIndex + 1; index < events.length; index += 1) { + const event = events[index] as SessionEvent + if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') + && sameStep(event, position)) { + advanceStepTiming(state, event) + } + } + return state.active?.bucket +} + +/** + * The running agent's phase glyph, or `undefined` when idle. A running turn + * with no open step falls back to the pre-first-token wait so a glyph is always + * available while the agent works; it fades in on turn start, throbs while the + * turn runs, and fades out on turn end (see {@link fadeGlyph}). + * @param events - Session events to derive the phase from. + * @param running - Whether the agent is currently running. + * @returns The phase glyph, or `undefined` when idle. + */ +export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined { + if (!running) return undefined + const bucket = openStepPhase(events) ?? 'ttft' + return TIMING_BUCKET_GLYPHS[bucket] +} + +/** + * The running throb's brightness at continuous clock `nowMs`: a cosine between + * {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the + * dim glyph breathes without ever blinking off. Multiplied by the fade envelope + * to gate appear/disappear. + * + * @param nowMs - Monotonic render clock in milliseconds. + * @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1]. + */ +export function pulseLevel(nowMs: number): number { + const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS + const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase) + return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave +} + +/** + * One frame of the running glyph at fade `opacity` (0 = invisible trough, + * 1 = settled dim gray). The character and its width never change — only the + * gray fades — so the prompt caret column stays fixed and the glyph reads as + * the caret dimly appearing and disappearing, never a colored indicator. + * + * With truecolor the glyph's 24-bit gray foreground interpolates between + * {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade and the + * running throb render as brightness; below {@link STATUS_FADE_MIN_OPACITY} it + * is hidden entirely so the pulse trough disappears. Without truecolor there is + * no per-frame gray, so `visible` (driven by the fade envelope, not the opacity) + * shows the glyph in the palette's muted role or leaves a blank column — a + * single dim appear/disappear at fixed width, still dim rather than accent, and + * no throb-driven blink. With color off entirely a visible glyph is bare, + * holding the caret column on a monochrome terminal. + * + * @param glyph - The phase glyph to paint. + * @param palette - Active palette supplying the muted (dim gray) role. + * @param colorEnabled - Whether ANSI is emitted at all. + * @param truecolor - Whether the terminal accepts 24-bit foreground codes. + * @param opacity - Brightness fraction in [0, 1] for the truecolor gray. + * @param visible - Whether the non-truecolor fallback shows the glyph at all. + * @returns The dim-gray glyph at this opacity, or a single space when hidden. + */ +export function fadeGlyph( + glyph: string, + palette: Palette, + colorEnabled: boolean, + truecolor: boolean, + opacity: number, + visible: boolean, +): string { + if (truecolor && colorEnabled) { + const o = Math.min(Math.max(opacity, 0), 1) + // Below the visibility threshold the glyph is fully hidden, so the pulse + // trough disappears rather than lingering as a near-background gray. + if (o < STATUS_FADE_MIN_OPACITY) return ' ' + const [tr, tg, tb] = STATUS_FADE_GRAY.trough + const [sr, sg, sb] = STATUS_FADE_GRAY.settled + const r = Math.round(tr + (sr - tr) * o) + const g = Math.round(tg + (sg - tg) * o) + const b = Math.round(tb + (sb - tb) * o) + return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m` + } + if (!visible) return ' ' + return colorEnabled ? palette.muted(glyph) : glyph +} + +/** + * Format a non-negative elapsed span at 100 ms resolution. + * @param elapsedMs - Elapsed milliseconds. + * @returns The formatted duration (e.g. `1.5s`, `2m03.4s`). + */ +export function formatStatusDuration(elapsedMs: number): string { + const tenths = Math.floor(Math.max(0, elapsedMs) / 100) + const seconds = tenths / 10 + if (seconds < 60) return `${seconds.toFixed(1)}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s` +} + +/** + * Format the non-zero timing buckets of one step as a middot-joined summary. + * @param totals - Per-phase totals to format. + * @param includeModelWait - Whether to always include the model-wait bucket. + * @returns The formatted timing summary. + */ +export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string { + return TIMING_BUCKETS + .filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft')) + .map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`) + .join(' · ') +} + +/** + * Format the queued-steering badge shown on the running status line. + * @param queued - Number of queued steering messages. + * @returns The badge text, or `undefined` when nothing is queued. + */ +export function formatQueuedStatus(queued: number): string | undefined { + return queued > 0 ? `${queued} queued` : undefined +} + +/** + * Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time. + * @param time - Epoch milliseconds. + * @returns The formatted local timestamp. + */ +export function formatCompletionTime(time: number): string { + const date = new Date(time) + const parts = [ + date.getFullYear().toString().padStart(4, '0'), + (date.getMonth() + 1).toString().padStart(2, '0'), + date.getDate().toString().padStart(2, '0'), + ] + const clock = [date.getHours(), date.getMinutes(), date.getSeconds()] + .map(value => value.toString().padStart(2, '0')) + .join(':') + return `${parts.join('-')} ${clock}` +} diff --git a/packages/ui/tui/src/session/tokens.ts b/packages/ui/tui/src/session/tokens.ts new file mode 100644 index 0000000000..1711c96ede --- /dev/null +++ b/packages/ui/tui/src/session/tokens.ts @@ -0,0 +1,96 @@ +/** + * Running token accounting for the terminal footer. Usage is keyed per + * turn/step so replayed or re-emitted usage replaces rather than double-counts. + * @module @deepseek-ai/dsh-tui/session/tokens + */ + +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Running token totals for the footer, keyed per turn/step so replayed or + * re-emitted usage replaces rather than double-counts; `input` is uncached + * input, cache buckets are disjoint. + */ +export interface SessionTokenTotals { + input: number + output: number + cacheRead: number + cacheWrite: number + readonly byStep: Map +} + +/** + * Fold one step's usage into the running totals, replacing any prior usage + * logged for the same turn/step. + * @param totals - Running totals mutated in place. + * @param turn - Turn index of the usage. + * @param step - Step index of the usage. + * @param usage - The step's token usage. + */ +export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = totals.byStep.get(key) + if (previous !== undefined) { + totals.input -= previous.inputTokens + totals.output -= previous.outputTokens + totals.cacheRead -= previous.cacheReadTokens ?? 0 + totals.cacheWrite -= previous.cacheWriteTokens ?? 0 + } + totals.byStep.set(key, usage) + totals.input += usage.inputTokens + totals.output += usage.outputTokens + totals.cacheRead += usage.cacheReadTokens ?? 0 + totals.cacheWrite += usage.cacheWriteTokens ?? 0 +} + +/** + * Fold a usage-bearing session event into the running totals. + * @param totals - Running totals mutated in place. + * @param event - Session event; ignored when it carries no usage. + */ +export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) + } +} + +/** + * Share of billed input (prompt) tokens served from the provider cache, as an + * integer percent, or `undefined` before any input is billed (avoids 0/0 and a + * meaningless rate on an empty session). + * @param totals - Running totals to measure. + * @returns The cache hit rate percent, or `undefined` when no input is billed. + */ +export function cacheHitRate(totals: SessionTokenTotals): number | undefined { + const billedInput = totals.input + totals.cacheRead + totals.cacheWrite + if (billedInput === 0) return undefined + return Math.round((totals.cacheRead / billedInput) * 100) +} + +/** + * Fold every usage-bearing event in a session into fresh totals. + * @param session - Session whose events supply usage. + * @returns The accumulated token totals. + */ +export function sessionTokens(session: Session): SessionTokenTotals { + const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() } + for (const event of session.events) { + recordEventUsage(totals, event) + } + return totals +} + +/** + * Format a token count with a compact k/m suffix for the footer. + * @param value - Token count. + * @returns The compact display string. + */ +export function formatTokens(value: number): string { + if (value < 1_000) return String(value) + if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` + if (value < 1_000_000) return `${Math.round(value / 1_000)}k` + return `${(value / 1_000_000).toFixed(1)}m` +} diff --git a/packages/ui/tui/src/skill-invocation.ts b/packages/ui/tui/src/skill-invocation.ts new file mode 100644 index 0000000000..5857e93ea3 --- /dev/null +++ b/packages/ui/tui/src/skill-invocation.ts @@ -0,0 +1,67 @@ +/** + * Manual `/skill: [instructions]` parsing and model-visible rendering for + * the terminal front door. + * @module @deepseek-ai/dsh-tui/skill-invocation + */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill' + +/** Prefix that marks an editor submission as a manual skill invocation. */ +export const SKILL_COMMAND_PREFIX = '/skill:' + +/** Parsed `/skill: [instructions]` submission; `name` is empty when the prefix carries no name. */ +export interface ParsedSkillCommand { + /** Skill name typed after `/skill:`, up to the first space. */ + name: string + /** Trimmed text after the name; empty when none was typed. */ + instructions: string +} + +/** + * Split a `/skill: [instructions]` submission into its name and trailing instructions. + * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}. + * @returns the skill name and any trailing instructions. + */ +export function parseSkillCommand(text: string): ParsedSkillCommand { + const rest = text.slice(SKILL_COMMAND_PREFIX.length) + const spaceIndex = rest.indexOf(' ') + if (spaceIndex === -1) return { name: rest, instructions: '' } + return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() } +} + +/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */ +function skillResourceReference(base: SkillResourceBase | undefined): string | undefined { + if (base === undefined) return undefined + switch (base.kind) { + case 'directory': + return `References in this skill are relative to ${base.path}.` + case 'url': + return `References in this skill are relative to ${base.url}.` + case 'opaque': + return base.description + default: + return assertNever(base, 'SkillResourceBase.kind') + } +} + +/** + * Render a manually invoked skill into the model-visible user-message text. The + * `` block carries the body and, when the provider supplies one, its + * resource base; the trimmed `instructions` follow the block as the user's + * request for this turn. The name is registry-validated kebab-case + * (the skill registry rejects any other) and the resource base is trusted + * same-process provider prose, so — unlike the model-facing `dsh-tool-skill` + * result, which escapes for a tool channel — this user turn is assembled raw. + * @param skill - the loaded skill definition. + * @param instructions - trimmed text typed after `/skill:`; empty when absent. + * @returns the user-message text delivered to the agent. + */ +export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string { + const lines = [``] + const reference = skillResourceReference(skill.resourceBase) + if (reference !== undefined) lines.push(reference, '') + lines.push(skill.content, '') + const block = lines.join('\n') + return instructions === '' ? block : `${block}\n\n${instructions}` +} diff --git a/packages/ui/tui/src/xml-tool-output.ts b/packages/ui/tui/src/xml-tool-output.ts new file mode 100644 index 0000000000..3e88120f7d --- /dev/null +++ b/packages/ui/tui/src/xml-tool-output.ts @@ -0,0 +1,138 @@ +/** Conservative readable-tree rendering for model-facing text containing one XML document. */ + +import { SaxesParser } from 'saxes' + +interface XmlElement { + readonly name: string + readonly attributes: readonly XmlAttribute[] + readonly children: XmlNode[] +} + +interface XmlAttribute { + readonly name: string + readonly value: string +} + +type XmlNode = XmlElement | string + +function parseXml(source: string, display: (text: string) => string): XmlElement | undefined { + const parser = new SaxesParser({ xmlns: false }) + const stack: XmlElement[] = [] + let root: XmlElement | undefined + const state = { invalid: false } + const reject = (): void => { state.invalid = true } + parser.on('opentag', (tag) => { + const element: XmlElement = { + name: tag.name, + // Attribute values and text pass through `display` because character references can + // expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping + // of the raw source never saw. Element names cannot carry them: control characters + // are not XML name characters and character references do not apply inside names. + attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })), + children: [], + } + const parent = stack.at(-1) + if (parent === undefined) { + if (root !== undefined) reject() + root = element + } else { + parent.children.push(element) + } + stack.push(element) + }) + parser.on('text', (text) => { + const parent = stack.at(-1) + if (parent === undefined) { + if (text.trim() !== '') reject() + } else { + parent.children.push(display(text)) + } + }) + parser.on('cdata', (text) => { + const parent = stack.at(-1) + if (parent === undefined) reject() + else parent.children.push(display(text)) + }) + parser.on('closetag', () => { stack.pop() }) + parser.on('xmldecl', reject) + parser.on('processinginstruction', reject) + parser.on('doctype', reject) + parser.on('comment', reject) + parser.on('error', reject) + parser.write(source).close() + return state.invalid ? undefined : root +} + +function elementLabel(element: XmlElement): string { + const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ') + return attributes === '' ? element.name : `${element.name} (${attributes})` +} + +function meaningfulChildren(element: XmlElement): readonly XmlNode[] { + return element.children.filter(child => typeof child !== 'string' || child.trim() !== '') +} + +function textBlock(text: string, depth: number): string[] { + return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`) +} + +function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] { + const indent = ' '.repeat(depth) + const children = meaningfulChildren(element) + if (children.length === 0) return [`${indent}${label(elementLabel(element))}`] + if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) { + return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`] + } + const lines = [`${indent}${label(elementLabel(element))}`] + for (const child of children) { + if (typeof child === 'string') lines.push(...textBlock(child, depth + 1)) + else lines.push(...treeLines(child, depth + 1, label)) + } + return lines +} + +function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] { + if (lines.length <= limit) return [...lines] + const head = Math.ceil(limit / 2) + const tail = limit - head + return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)] +} + +/** + * Render a complete XML document as an indented tree, or decline without changing partial/mixed text. + * @param source - Raw model-facing text from a context message or unknown tool result. + * @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and + * to the number of top-level children, so many siblings cannot grow the collapsed card without bound. + * @param expanded - Whether to retain every rendered child line. + * @param display - Escapes parsed text and attribute values for terminal output; character references + * can expand to control characters that pre-parse escaping never saw. + * @param label - Styles element names and attributes. + * @param omitted - Renders the omitted-line marker for a collapsed child or child range. + * @returns Tree rows, or `undefined` when `source` is not one supported complete XML document. + */ +export function renderUnknownXml( + source: string, + maxChildLines: number, + expanded: boolean, + display: (text: string) => string, + label: (text: string) => string, + omitted: (count: number) => string, +): string[] | undefined { + const root = parseXml(source, display) + if (root === undefined) return undefined + const blocks = meaningfulChildren(root).map(child => + typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label)) + const rootLine = label(elementLabel(root)) + if (expanded) return [rootLine, ...blocks.flat()] + const previewed = blocks.map(block => preview(block, maxChildLines, omitted)) + if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()] + const head = Math.ceil(maxChildLines / 2) + const tail = maxChildLines - head + const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0) + return [ + rootLine, + ...previewed.slice(0, head).flat(), + omitted(hidden), + ...previewed.slice(previewed.length - tail).flat(), + ] +} diff --git a/packages/ui/tui/tests/extension.spec.ts b/packages/ui/tui/tests/extension.spec.ts index bf11d6c601..a7eec5589e 100644 --- a/packages/ui/tui/tests/extension.spec.ts +++ b/packages/ui/tui/tests/extension.spec.ts @@ -11,12 +11,12 @@ import type { TuiOverlayOptions, TuiOverlaySession, TuiTheme, -} from '../src/extension.ts' +} from '../src/extension/types.ts' import { TuiExtensionServiceImpl, TuiOverlayManager, type TuiOverlayDriver, -} from '../src/overlay-manager.ts' +} from '../src/extension/overlay-manager.ts' const theme: TuiTheme = Object.freeze({ text: (value: string) => `text:${value}`, diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c74c469133..e0e0c027da 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -17,10 +17,11 @@ import type { import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' import { TestSessionQueryService } from './session-query.ts' +import TuiPromptService from '../src/prompt.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -43,6 +44,7 @@ export interface TuiHarnessOptions { beforeMount?: (session: Session) => void cwd?: string | null formatCwd?: TuiRuntime['formatCwd'] + gitBranch?: TuiRuntime['gitBranch'] /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */ agentOptions?: AgentOptions contextWindow?: number @@ -93,6 +95,7 @@ export async function createTuiTestHarness 'tui-staging'), }) return { ctx, session, agent, terminal, exit, controller } } diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index c5c497fab9..73c39db268 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => { 'llm', 'systemPrompt', 'tokenMeter', + 'tuiPrompt', ]) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') diff --git a/packages/ui/tui/tests/prompt.spec.ts b/packages/ui/tui/tests/prompt.spec.ts new file mode 100644 index 0000000000..f212ef10e2 --- /dev/null +++ b/packages/ui/tui/tests/prompt.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import TuiPromptService, { + parseTuiPromptTemplate, + renderTuiPromptTemplate, +} from '../src/prompt.ts' + +const tick = (): Promise => new Promise((resolve) => { queueMicrotask(resolve) }) + +describe('TUI prompt values', () => { + it('registers, updates, and disposes mutable values', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + + const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m') + value.set('next') + expect(ctx.tuiPrompt.get('git/worktree')).toBe('next') + + value.set(undefined) + expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined() + value.dispose() + expect(() => { value.set('late') }).toThrow(/disposed/) + await ctx.fiber.dispose() + }) + + it('coalesces a change burst into one notification and contains each observer', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Capture the containment warnings so the rejected-promise and sync-throw + // paths are each pinned (removing either catch drops its warning). + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + // A synchronous thrower, an async rejecter, and a thrower whose error is + // hostile to string coercion all sit BEFORE the observed listener, so + // proving `after` still runs proves none of them starves it (a naive + // `String(error)` inside the containment would itself throw on the last). + const hostile = { toString() { throw new Error('hostile coercion') } } + const thrower = vi.fn(() => { throw new Error('sync observer boom') }) + const rejecter = vi.fn(async () => { throw new Error('async observer boom') }) + const hostileThrower = vi.fn(() => { throw hostile }) + const after = vi.fn() + ctx.tuiPrompt.subscribe(thrower) + ctx.tuiPrompt.subscribe(rejecter) + ctx.tuiPrompt.subscribe(hostileThrower) + const unsubscribe = ctx.tuiPrompt.subscribe(after) + await tick() // drain the registration notifications + thrower.mockClear() + rejecter.mockClear() + hostileThrower.mockClear() + after.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + value.set('b') // unchanged: no additional schedule + value.set('c') + await tick() + await tick() // settle the contained rejected promise + // One coalesced callback for the whole burst; a throwing, rejecting, or + // hostile-to-render observer is contained and does not stop later observers. + expect(thrower).toHaveBeenCalledTimes(1) + expect(rejecter).toHaveBeenCalledTimes(1) + expect(hostileThrower).toHaveBeenCalledTimes(1) + expect(after).toHaveBeenCalledTimes(1) + // Each contained failure logged its own warning: the sync throw, the + // rejected promise, and the hostile-to-render throw (via non-throwing + // errorChain). Pinning the rejected-promise warning fails if its `.catch` + // containment is removed. + expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true) + expect(warnings.some(w => w.includes('threw: '))).toBe(true) + + // Unsubscribe stops further notifications for that listener. + unsubscribe() + value.set('d') + await tick() + expect(after).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('removes a subscription when the subscriber fiber disposes', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const observed = vi.fn() + // Subscribe from a child plugin fiber that shares the service, then dispose + // only that fiber; the effect-owned subscription must go with it. + const child = ctx.plugin({ + inject: ['tuiPrompt'], + apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) }, + }) + await tick() + observed.mockClear() + await child.dispose() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(observed).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('keeps one fiber\'s subscription when another disposes the same callback', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + // Both fibers subscribe the SAME function reference. Per-subscription record + // identity (not callback identity) keeps them independent, so disposing one + // must not silence the other. + const shared = vi.fn() + const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } }) + await tick() + await first.dispose() + shared.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + // The second fiber's subscription survives the first's disposal. + expect(shared).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it('does not notify a subscription unsubscribed earlier in the same burst', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + const victim = vi.fn() + // This listener is delivered first (subscribed first) and synchronously + // unsubscribes the victim during the same notification. The snapshot must + // re-check liveness so the later victim record does not fire this burst. + ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() }) + const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim) + await tick() + victim.mockClear() + + const value = ctx.tuiPrompt.register('git/worktree', 'a') + value.set('b') + await tick() + expect(victim).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('rejects invalid and duplicate names', async () => { + const ctx = new Context() + await ctx.plugin(TuiPromptService) + expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/) + ctx.tuiPrompt.register('status') + expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/) + await ctx.fiber.dispose() + }) +}) + +describe('TUI prompt templates', () => { + it('interpolates values and removes separators around unavailable values', () => { + const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}') + const values = new Map([['cwd', '/work'], ['model', 'deepseek']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek') + }) + + it('keeps a trailing literal after the last value', () => { + const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ') + const values = new Map([['symbol', 'dsh'], ['indicator', '●']]) + expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ') + }) + + it('preserves trusted ANSI fragments', () => { + const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m' + expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline) + }) +}) diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index f0b5e0eb61..522674172e 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import CommandService from '@deepseek-ai/dsh-commands' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' -import { createTuiChat } from '../src/index.ts' +import { createTuiChat, TuiPromptService } from '../src/index.ts' import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' @@ -48,6 +48,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { describe('TUI session-reference snapshot', () => { it('snapshots compacted current-surface context on send and displays only its reference card', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime()) const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -56,6 +57,7 @@ describe('TUI session-reference snapshot', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) @@ -94,7 +96,7 @@ describe('TUI session-reference snapshot', () => { const controller = createTuiChat(ctx, { sessionId: target.id, welcome: 'Session reference snapshot.', - color: true, + theme: { color: true }, title: 'DSH session reference', }, { terminal, exit: () => {} }) await terminal.waitForFrame(0) @@ -140,5 +142,6 @@ describe('TUI session-reference snapshot', () => { await controller.dispose() await ctx.fiber.dispose() await terminal.dispose() + clock.mockRestore() }) }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 0e3c73750a..f0fd74b989 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,99 +1,69 @@ terminal 100x40 buffer=normal length=40 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=36 bufferRow=36 +cursor hidden column=7 viewportRow=34 bufferRow=34 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ … +4 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -9| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -10| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +10| "[exit 0] " + style 0-7 dim 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -14| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -15| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -16| "▌ … +5 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -17| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -22| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -23| "▌ " - style 0-0 fg=green -24| -25| "▌ " - style 0-0 fg=green -26| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -27| "▌ audit complete " - style 0-0 fg=green -28| "▌ [status: completed] " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| "▌ " - style 0-0 fg=green -32| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -33| "▌ Loaded review instructions. " - style 0-0 fg=green -34| "▌ " - style 0-0 fg=green -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| " " - style 1-1 inverse -37| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim -39| +12| "● Tool / edit" + style 0-12 fg=green +13| "src/view.ts " + style 0-10 bold +14| "- old line " + style 0-9 fg=red +15| "… +3 lines (Ctrl+O to expand) " + style 0-28 dim +16| "└ +2 -2 · 1 file " + style 0-15 dim +17| +18| "● Tool / subagent" + style 0-16 fg=green +19| "Delegate renderer audit " +20| "The renderer has explicit lifecycle ownership. " +21| +22| "● Tool / task_output" + style 0-19 fg=green +23| "Read output from background task subagent-7 " +24| " " +25| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +26| " " +27| +28| "● Tool / skill" + style 0-13 fg=green +29| "Load skill dsh-code-review " +30| "Loaded review instructions. " +31| "Model wait 0.0s " + style 0-14 dim +32| +33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +34| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 2321eff61d..05e1f11b3c 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,117 +1,79 @@ -terminal 100x40 buffer=normal length=48 base=8 viewport=8 +terminal 100x40 buffer=normal length=43 base=3 viewport=3 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=37 bufferRow=45 +cursor hidden column=7 viewportRow=39 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=green -5| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -6| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -7| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -8| "▌ packages/ui/tui 100% " - style 0-0 fg=green -9| "▌ 4016 tests passed " - style 0-0 fg=green -10| "▌ 1 test skipped " - style 0-0 fg=green -11| "▌ coverage complete " - style 0-0 fg=green -12| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -13| "▌ " - style 0-0 fg=green +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +7| "$ pnpm run test:coverage " + style 0-23 fg=cyan +8| "/workspace/project " + style 0-17 dim +9| "packages/ui/tui 100% " +10| "4016 tests passed " +11| "1 test skipped " +12| "coverage complete " +13| "[exit 0] " + style 0-7 dim 14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Edit renderer " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -17| "▌ src/view.ts " - style 0-0 fg=green - style 2-12 bold -18| "▌ - old line " - style 0-0 fg=green - style 2-11 fg=red -19| "▌ - keep " - style 0-0 fg=green - style 2-7 fg=red -20| "▌ + new line " - style 0-0 fg=green - style 2-11 fg=green -21| "▌ + keep " - style 0-0 fg=green - style 2-7 fg=green -22| "▌ " - style 0-0 fg=green -23| "▌ tests/view.spec.ts " - style 0-0 fg=green - style 2-19 bold -24| "▌ + expect(screen).toMatchSnapshot() " - style 0-0 fg=green - style 2-35 fg=green -25| "▌ " - style 0-0 fg=green +15| "● Tool / edit" + style 0-12 fg=green +16| "src/view.ts " + style 0-10 bold +17| "- old line " + style 0-9 fg=red +18| "- keep " + style 0-5 fg=red +19| "+ new line " + style 0-9 fg=green +20| "+ keep " + style 0-5 fg=green +21| "└ +2 -2 · 1 file " + style 0-15 dim +22| +23| "● Tool / subagent" + style 0-16 fg=green +24| "Delegate renderer audit " +25| "The renderer has explicit lifecycle ownership. " 26| -27| "▌ " - style 0-0 fg=green -28| "▌ ✓ Delegate renderer audit " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -29| "▌ The renderer has explicit lifecycle ownership. " - style 0-0 fg=green -30| "▌ " - style 0-0 fg=green -31| -32| "▌ " - style 0-0 fg=green -33| "▌ ✓ Read output from background task subagent-7 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-46 bold -34| "▌ audit complete " - style 0-0 fg=green -35| "▌ [status: completed] " - style 0-0 fg=green -36| "▌ " - style 0-0 fg=green -37| -38| "▌ " - style 0-0 fg=green -39| "▌ ✓ Load skill dsh-code-review " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-29 bold -40| "▌ Loaded review instructions. " - style 0-0 fg=green -41| "▌ " - style 0-0 fg=green -42| -43| " Tool cards expanded. " - style 1-20 fg=bright-black -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded" - style 0-43 dim - style 74-99 dim +27| "● Tool / task_output" + style 0-19 fg=green +28| "Read output from background task subagent-7 " +29| " " +30| "console " + style 0-6 dim +31| " started background task bash-5 " + style 2-31 fg=cyan +32| " " +33| +34| "● Tool / skill" + style 0-13 fg=green +35| "Load skill dsh-code-review " +36| "Loaded review instructions. " +37| "Model wait 0.0s " + style 0-14 dim +38| +39| "Tool cards expanded. " + style 0-19 fg=bright-black +40| +41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +42| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt index 59d1377702..0477513ebd 100644 --- a/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt +++ b/packages/ui/tui/tests/snapshots/banner-gradient.expected.txt @@ -1,7 +1,7 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=4 bufferRow=4 +cursor hidden column=7 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-1 fg=#4d6bfe bold @@ -15,15 +15,22 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -7-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-35| diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index c246a00b69..db131247d0 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -1,39 +1,37 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=12 bufferRow=12 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=yellow -5| "▌ ◌ Echo two markers and combine them " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-36 bold -6| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) " - style 0-0 fg=yellow -7| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) " - style 0-0 fg=yellow -8| "▌ console.log(first, second) " - style 0-0 fg=yellow -9| "▌ return `${first}+${second}` " - style 0-0 fg=yellow -10| "▌ " - style 0-0 fg=yellow -11| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -12| " " - style 1-1 inverse -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -15-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / run_code" + style 0-16 fg=yellow +7| "Echo two markers and combine them " +8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) " +9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) " +10| "console.log(first, second) " +11| "return `${first}+${second}` " +12| "Model wait 0.0s " + style 0-14 dim +13| +14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 52e003eea0..db68564b48 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,46 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=active title "DSH snapshot" -cursor hidden column=1 viewportRow=17 bufferRow=17 +cursor hidden column=7 viewportRow=18 bufferRow=18 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Show the live update. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Inspecting width and styles. " - style 1-28 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Streaming visible state… " - style 11-23 bold -14| -15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels " - style 1-1 fg=bright-blue - style 3-62 fg=bright-black -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -17| " " - style 1-1 inverse -18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 fg=bright-blue -19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -20-35| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Inspecting width and styles. " + style 0-27 fg=bright-black italic +7| "Streaming visible state… " + style 10-22 bold +8| " " +9| "ts " + style 0-1 dim +10| " const visible = true " + style 2-21 fg=cyan +11| " " +12| "Model wait 1.0s · Thinking 2.0s " + style 0-30 dim +13| +14| "You " + style 0-2 fg=bright-blue bold underline +15| "Show the live update. " +16| +17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +18| " dsh ● press enter to steer and esc to cancel " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-44 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 47c225008d..df927629c9 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -1,49 +1,45 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=16 bufferRow=16 +cursor hidden column=7 viewportRow=21 bufferRow=21 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ ◌ Inspect cordis runtime: tools " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-32 bold +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| -6| "▌ " - style 0-0 fg=yellow -7| "▌ ◌ Mount plugin into live cordis runtime " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-40 bold -8| "▌ { " - style 0-0 fg=yellow -9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " - style 0-0 fg=yellow -10| "▌ ready: true }) } }\" " - style 0-0 fg=yellow -11| "▌ } " - style 0-0 fg=yellow -12| "▌ " - style 0-0 fg=yellow -13| -14| "▌ ◌ Unmount dyn-1 " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-16 bold -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| " " - style 1-1 inverse -17| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -19-35| +6| "○ Tool / cordis_inspect" + style 0-22 fg=yellow +7| "Inspect cordis runtime: tools " +8| +9| "○ Tool / cordis_mount" + style 0-20 fg=yellow +10| "Mount plugin into live cordis runtime " +11| "{ " +12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:" +13| "true }) } }\" " +14| "} " +15| +16| "○ Tool / cordis_unmount" + style 0-22 fg=yellow +17| "Unmount dyn-1 " +18| "Model wait 0.0s " + style 0-14 dim +19| +20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +21| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +22-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index b6fb49135b..9be96fe5e1 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,63 +1,78 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=31 +cursor visible column=0 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +37| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index ace55782e9..9826588b32 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -1,46 +1,40 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=15 bufferRow=15 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=yellow -5| "▌ ◌ workflow: tui-matrix " - style 0-0 fg=yellow - style 2-2 fg=yellow bold - style 3-23 bold -6| "▌ phase('Inspect') " - style 0-0 fg=yellow -7| "▌ const reports = await parallel([ " - style 0-0 fg=yellow -8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " - style 0-0 fg=yellow -9| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=yellow - style 2-30 dim -10| "▌ ]) " - style 0-0 fg=yellow -11| "▌ phase('Verify') " - style 0-0 fg=yellow -12| "▌ return { reports, verdict: 'covered' } " - style 0-0 fg=yellow -13| "▌ " - style 0-0 fg=yellow -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| " " - style 1-1 inverse -16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "○ Tool / workflow" + style 0-16 fg=yellow +7| "workflow: tui-matrix " +8| "phase('Inspect') " +9| "const reports = await parallel([ " +10| "… +2 lines (Ctrl+O to expand) " + style 0-28 dim +11| "]) " +12| "phase('Verify') " +13| "return { reports, verdict: 'covered' } " +14| "Model wait 0.0s " + style 0-14 dim +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +17| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse 18-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 05c35e32e1..f93a4b47da 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,63 +1,77 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=37 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=7 viewportRow=31 bufferRow=36 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Keyboard shortcuts " - style 1-18 fg=bright-blue bold -5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " - style 1-61 fg=bright-black -6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " - style 1-75 fg=bright-black -7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " - style 1-73 fg=bright-black -8| " " -9| " /clear — Clear the transcript view (session history is unchanged) " - style 1-65 fg=bright-black -10| " /exit — Exit after the active turn reaches idle " - style 1-47 fg=bright-black -11| " /help — Show keyboard shortcuts and commands " - style 1-44 fg=bright-black -12| " /model [[provider/]model] — Show or switch this session's model " - style 1-63 fg=bright-black -13| " /reasoning — Toggle reasoning blocks " - style 1-36 fg=bright-black -14| " /redraw — Invalidate components and redraw the terminal " - style 1-55 fg=bright-black -15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " - style 1-88 fg=bright-black -16| " /resume — List this workspace's resumable sessions " - style 1-50 fg=bright-black -17| " /status — Show detailed session diagnostics " - style 1-43 fg=bright-black -18| " /tools — Expand or collapse all tool cards " - style 1-42 fg=bright-black -19| " /skill: [instructions] — load a skill into the conversation " - style 1-65 fg=bright-black -20| -21| " provider stream failed after partial output " - style 1-43 fg=red -22| -23| " The previous process ended during this turn. " - style 1-44 fg=yellow +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 " + style 0-46 dim +6| +7| "Keyboard shortcuts " + style 0-17 fg=bright-blue bold +8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 0-60 fg=bright-black +9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " + style 0-74 fg=bright-black +10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 0-72 fg=bright-black +11| " " +12| "/clear — Clear the transcript view (session history is unchanged) " + style 0-64 fg=bright-black +13| "/exit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +14| "/help — Show keyboard shortcuts and commands " + style 0-43 fg=bright-black +15| "/model [[provider/]model] — Show or switch this session's model " + style 0-62 fg=bright-black +16| "/quit — Exit after the active turn reaches idle " + style 0-46 fg=bright-black +17| "/reasoning — Toggle reasoning blocks " + style 0-35 fg=bright-black +18| "/redraw — Invalidate components and redraw the terminal " + style 0-54 fg=bright-black +19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " + style 0-87 fg=bright-black +20| "/resume — List this workspace's resumable sessions " + style 0-49 fg=bright-black +21| "/status — Show session diagnostics, system prompt, and registered tools " + style 0-70 fg=bright-black +22| "/tools — Expand or collapse all tool cards " + style 0-41 fg=bright-black +23| "/skill: [instructions] — load a skill into the conversation " + style 0-64 fg=bright-black 24| -25| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -26| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -30-31| +25| "provider stream failed after partial output " + style 0-42 fg=red +26| +27| "The previous process ended during this turn. " + style 0-43 fg=yellow +28| +29| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow +30| +31| "Turn ended: plugin-policy. " + style 0-25 fg=yellow +32| +33| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +34| +35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +36| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt index 174f21a0f4..73b7c494c5 100644 --- a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt +++ b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt @@ -1,24 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=5 viewportRow=4 bufferRow=4 +cursor hidden column=11 viewportRow=8 bufferRow=8 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -4| " @tsc " - style 5-5 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -6| " → File · terminal-special-case.t src/terminal-special-case.ts " - style 1-32 fg=bright-blue -7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -8-35| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > @tsc " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 11-11 inverse +9| " → File · terminal-special-case.t src/terminal-special-case.ts " + style 7-38 fg=bright-blue +10-35| diff --git a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt deleted file mode 100644 index cef660ff49..0000000000 --- a/packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt +++ /dev/null @@ -1,42 +0,0 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=92 viewportRow=15 bufferRow=15 -buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| -13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " - style 8-83 fg=bright-blue -14| " │ deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " - style 8-8 fg=bright-blue - style 38-77 fg=bright-black - style 83-83 fg=bright-blue -15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " - style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse - style 83-83 fg=bright-blue -16| " │ │ " - style 8-8 fg=bright-blue - style 83-83 fg=bright-blue -17| " │ ↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel │ " - style 8-8 fg=bright-blue - style 10-71 dim - style 83-83 fg=bright-blue -18| " ╰──────────────────────────────────────────────────────────────────────────╯ " - style 8-83 fg=bright-blue -19-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt index c86b1f1f3d..df2dbf5e33 100644 --- a/packages/ui/tui/tests/snapshots/model-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -8,27 +8,34 @@ buffer style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-12| +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-12| 13| " ╭ Select model ────────────────────────────────────────────────────────────╮ " style 8-83 fg=bright-blue -14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — High — current │ " +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " style 8-8 fg=bright-blue - style 10-77 fg=bright-blue inverse + style 10-70 fg=bright-blue inverse style 83-83 fg=bright-blue -15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro — provider default │ " +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " style 8-8 fg=bright-blue - style 36-77 fg=bright-black + style 36-58 fg=bright-black style 83-83 fg=bright-blue 16| " │ │ " style 8-8 fg=bright-blue diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt index 4e711e37c9..9bba7a9b14 100644 --- a/packages/ui/tui/tests/snapshots/model-switching.expected.txt +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -1,27 +1,32 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=7 bufferRow=7 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| " Model selected: deepseek/deepseek-v4-pro. Reasoning effort: provider default. New steps " - style 1-91 fg=bright-black -5| " will use it. " - style 1-12 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -9| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-41 dim - style 65-91 dim -10-31| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 0-63 fg=bright-black +8| +9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black + style 58-67 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt new file mode 100644 index 0000000000..02d1ff0c97 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -0,0 +1,40 @@ +terminal 56x20 buffer=normal length=20 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=19 bufferRow=19 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-55 fg=bright-black +8| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +9-11| +12| " " +13| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 fg=bright-black +14| " Continue with this change? " +15| " " +16| " › 1. Proceed Apply the proposed change " + style 2-13 fg=bright-blue bold + style 16-40 fg=bright-black +17| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 4e17a0e652..c5787378e0 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -8,12 +8,11 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline 5| " " 6| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 220f5dc1c7..611b8262d4 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -8,17 +8,14 @@ viewport style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────" - style 0-55 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────" - style 0-55 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context" - style 0-43 dim - style 46-55 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| 7| " " 8| " Question 1/3 (3 unanswered) · Coverage " style 2-39 fg=bright-black diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index accef4fffc..c6a3abac21 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -1,38 +1,34 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=13 bufferRow=13 +cursor hidden column=7 viewportRow=12 bufferRow=12 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Start then cancel. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Start then cancel. " +6| +7| "Retrying model request (1/2) in 1000ms: temporary transport failure " + style 0-66 fg=yellow 8| -9| " Retrying model request (1/2) in 1000ms: temporary transport failure " - style 1-67 fg=yellow +9| "Turn cancelled. " + style 0-14 fg=yellow 10| -11| " Turn cancelled. " - style 1-15 fg=yellow -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| " " - style 1-1 inverse -14| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -16-35| +11| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +12| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +13-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt index 1b038d5a83..c642694132 100644 --- a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Let the bounded policy exhaust. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Let the bounded policy exhaust. " +6| +7| "provider still unavailable " + style 0-25 fg=red 8| -9| " provider still unavailable " - style 1-26 fg=red -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt index c0ae86361c..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -1,39 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Recovered on the next bounded attempt. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -17-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt index 16dee8242a..4144bac651 100644 --- a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -1,35 +1,31 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=11 bufferRow=11 +cursor hidden column=7 viewportRow=10 bufferRow=10 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Recover this request. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Recover this request. " +6| +7| "Retrying model request (1/2) in 500ms: provider rate limit " + style 0-57 fg=yellow 8| -9| " Retrying model request (1/2) in 500ms: provider rate limit " - style 1-58 fg=yellow -10| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -11| " " - style 1-1 inverse -12| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 69-95 dim -14-35| +9| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +10| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +11-35| diff --git a/packages/ui/tui/tests/snapshots/session-reference.expected.txt b/packages/ui/tui/tests/snapshots/session-reference.expected.txt index 3cd62dd243..3e0d12f045 100644 --- a/packages/ui/tui/tests/snapshots/session-reference.expected.txt +++ b/packages/ui/tui/tests/snapshots/session-reference.expected.txt @@ -1,39 +1,35 @@ terminal 96x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH session reference" -cursor hidden column=1 viewportRow=14 bufferRow=14 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Session reference snapshot." style 1-27 fg=bright-black -2| " mock • target-session" - style 1-23 dim +2| " target-session" + style 1-14 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use @Source session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use @Source session " +6| +7| "Referenced sessions · Source session (source-session) " + style 0-52 dim 8| -9| " Referenced sessions · Source session (source-session) " - style 1-53 dim -10| -11| " Assistant " - style 1-9 fg=bright-magenta bold -12| " Combined reference request accepted. " -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "mock /workspace/project ↑0 ↓0 tools:collapsed" - style 0-30 dim - style 81-95 dim -17-23| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Combined reference request accepted. " +11| "Model wait 0.0s · Completed 2026-07-21 12:30:00 " + style 0-46 dim +12| +13| "/workspace/project mock ↑0 ↓0" + style 0-17 fg=bright-blue bold + style 20-23 fg=bright-black + style 26-30 fg=bright-black +14| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-23| diff --git a/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt new file mode 100644 index 0000000000..3b47364308 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/shell-prompt-multiline.expected.txt @@ -0,0 +1,31 @@ +terminal 44x18 buffer=normal length=18 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=38 viewportRow=13 bufferRow=13 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +8| " ↑ 1 more " + style 1-14 dim +9| " enough detail to wrap across multiple " +10| " full-width continuation rows without " +11| " leaving a prompt-sized gap at the right " +12| " edge. " +13| " Then suggest a simpler version. " + style 38-38 inverse +14-17| diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 4937592cc7..a22787ea49 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -1,110 +1,120 @@ -terminal 56x36 buffer=normal length=36 base=0 viewport=0 +terminal 56x36 buffer=normal length=44 base=8 viewport=8 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=32 bufferRow=32 +cursor hidden column=7 viewportRow=35 bufferRow=43 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ─────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ─────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-55 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort │" +15| "│ Model: deepseek/deepseek-v4-pro (effort │" style 0-0 dim style 3-12 fg=bright-black style 40-55 dim -17| "│ default; reasoning blocks shown) │" +16| "│ default; reasoning blocks shown) │" style 0-0 dim style 15-46 dim style 55-55 dim -18| "│ │" +17| "│ │" style 0-0 dim style 55-55 dim -19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -20| "│ tool call │" +19| "│ tool call │" style 0-0 dim style 55-55 dim -21| "│ │" +20| "│ │" style 0-0 dim style 55-55 dim -22| "│ Tokens: 1,250 input + 340 output │" +21| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" +22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 55-55 dim -24| "│ + 250 write) │" +23| "│ + 250 write) │" style 0-0 dim style 55-55 dim -25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" +24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 55-55 dim -26| "│ 128,000) │" +25| "│ 128,000) │" style 0-0 dim style 55-55 dim -27| "│ │" +26| "│ │" style 0-0 dim style 55-55 dim -28| "│ Created: 2026-07-22 09:10:11 UTC │" +27| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -29| "│ Active: 2026-07-22 09:10:11 UTC │" +28| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 55-55 dim -30| "╰──────────────────────────────────────────────────────╯" +29| "╰──────────────────────────────────────────────────────╯" style 0-55 dim -31| "────────────────────────────────────────────────────────" - style 0-55 dim -32| " " - style 1-1 inverse -33| "────────────────────────────────────────────────────────" - style 0-55 dim -34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6" - style 0-55 dim -35| +30| +31| "System prompt " + style 0-12 fg=bright-blue bold +32| "You are an AI agent powered by the DeepSeek Harness SDK." +33| " " +34| "Paths prefixed with @ are files explicitly referenced by" +35| "the user. Use the read tool when their contents are " +36| "needed; do not claim to have inspected a file before " +37| "reading it. " +38| +39| "Registered tools " + style 0-15 fg=bright-blue bold +40| "read, write " +41| +42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-55 fg=bright-black +43| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 915d4e58ef..d8fb37bac4 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -1,99 +1,107 @@ -terminal 92x32 buffer=normal length=32 base=0 viewport=0 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=28 bufferRow=28 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Inspect session diagnostics" style 1-27 fg=bright-black -2| " deepseek-v4-pro • main-session" - style 1-32 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ inspect this session " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Assistant " - style 1-9 fg=bright-magenta bold -10| " Session inspected. " -11| -12| "╭─ Session status ───────────────────────────────────────────────────────────────╮" +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Session inspected. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-blue bold underline +9| "inspect this session " +10| +11| "╭─ Session status ───────────────────────────────────────────────────────────────╮" style 0-2 dim style 3-16 fg=bright-blue bold style 17-81 dim -13| "│ Session: main-session │" +12| "│ Session: main-session │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -14| "│ Title: Inspect session diagnostics │" +13| "│ Title: Inspect session diagnostics │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -15| "│ Directory: /workspace/project │" +14| "│ Directory: /workspace/project │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -16| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" +15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │" style 0-0 dim style 3-12 fg=bright-black style 40-79 dim style 81-81 dim -17| "│ │" +16| "│ │" style 0-0 dim style 81-81 dim -18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" +17| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -19| "│ │" +18| "│ │" style 0-0 dim style 81-81 dim -20| "│ Tokens: 1,250 input + 340 output │" +19| "│ Tokens: 1,250 input + 340 output │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" +20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-26 fg=bright-blue style 27-32 dim style 81-81 dim -22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" +21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" style 0-0 dim style 3-12 fg=bright-black style 15-15 dim style 16-20 fg=bright-blue style 21-32 dim style 81-81 dim -23| "│ │" +22| "│ │" style 0-0 dim style 81-81 dim -24| "│ Created: 2026-07-22 09:10:11 UTC │" +23| "│ Created: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -25| "│ Active: 2026-07-22 09:10:11 UTC │" +24| "│ Active: 2026-07-22 09:10:11 UTC │" style 0-0 dim style 3-12 fg=bright-black style 81-81 dim -26| "╰────────────────────────────────────────────────────────────────────────────────╯" +25| "╰────────────────────────────────────────────────────────────────────────────────╯" style 0-81 dim -27| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -28| " " - style 1-1 inverse -29| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" - style 0-57 dim - style 64-91 dim -31| +26| +27| "System prompt " + style 0-12 fg=bright-blue bold +28| "You are an AI agent powered by the DeepSeek Harness SDK. " +29| " " +30| "Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when " +31| "their contents are needed; do not claim to have inspected a file before reading it. " +32| +33| "Registered tools " + style 0-15 fg=bright-blue bold +34| "read, write " +35| +36| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k ↓340 cache 67% 33% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-48 fg=bright-black + style 51-71 fg=bright-black + style 74-84 fg=bright-black +37| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt new file mode 100644 index 0000000000..728d7116b5 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/step-timing-completed.expected.txt @@ -0,0 +1,34 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=11 bufferRow=11 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Reasoning " + style 0-8 fg=bright-black italic +6| "Checking the result. " + style 0-19 fg=bright-black italic +7| "The result is ready. " +8| "Model wait 1.0s · Thinking 2.0s · Response 3.0s · Completed 2026-07-21 14:32:12 " + style 0-78 dim +9| +10| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +11| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +12-35| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index cb629bbccd..4eeb12de71 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -1,30 +1,36 @@ terminal 44x18 buffer=normal length=18 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=9 bufferRow=9 +cursor hidden column=7 viewportRow=15 bufferRow=15 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command " - style 1-43 fg=bright-black -6| " completed and its details were retired " - style 1-43 fg=bright-black -7| " from the active surface. " - style 1-24 fg=bright-black -8| "────────────────────────────────────────────" - style 0-43 dim -9| " " - style 1-1 inverse -10| "────────────────────────────────────────────" - style 0-43 dim -11| "deepseek-v4-flash /workspace/project ↑0 ↓0" - style 0-43 dim -12-17| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: " +10| "nested/AGENTS.md " +11| " " +12| " Render workspace context XML clearly. " +13| +14| "/workspace/project (tui-staging) deepseek-v" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-43 fg=bright-black +15| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +16-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index 6acf1e0483..74ca972da9 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -1,27 +1,37 @@ terminal 104x30 buffer=normal length=30 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=7 bufferRow=7 +cursor hidden column=7 viewportRow=14 bufferRow=14 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Context · compact " - style 1-17 dim -5| " Compacted summary: the prior command completed and its details were retired from the active surface. " - style 1-100 fg=bright-black -6| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -7| " " - style 1-1 inverse -8| "────────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-103 dim -9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 77-103 dim -10-29| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "Context · workspace-context " + style 0-26 dim +8| "system-reminder " + style 0-14 fg=bright-black +9| " Additional instructions from: nested/AGENTS.md " +10| " " +11| " Render workspace context XML clearly. " +12| +13| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +14| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +15-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index e69fb478b0..54f04ef787 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -1,59 +1,47 @@ terminal 80x24 buffer=normal length=24 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=20 bufferRow=20 +cursor hidden column=7 viewportRow=20 bufferRow=20 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Snapshot agent ready." style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Old prompt with a long line that exercises wrapping before compaction. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Old prompt with a long line that exercises wrapping before compaction. " 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ pnpm run test:coverage " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-25 bold -11| "▌ Run the coverage gate " - style 0-0 fg=green - style 2-22 fg=bright-black -12| "▌ /workspace/project " - style 0-0 fg=green - style 2-19 dim -13| "▌ packages/ui/tui 100% " - style 0-0 fg=green -14| "▌ … +1 lines (Ctrl+O to expand) " - style 0-0 fg=green - style 2-30 dim -15| "▌ 1 test skipped " - style 0-0 fg=green -16| "▌ coverage complete " - style 0-0 fg=green -17| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -18| "▌ " - style 0-0 fg=green -19| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -20| " " - style 1-1 inverse -21| "────────────────────────────────────────────────────────────────────────────────" - style 0-79 dim -22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 53-79 dim -23| +9| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +10| "$ pnpm run test:coverage " + style 0-23 fg=cyan +11| "/workspace/project " + style 0-17 dim +12| "packages/ui/tui 100% " +13| "… +1 lines (Ctrl+O to expand) " + style 0-28 dim +14| "1 test skipped " +15| "coverage complete " +16| "[exit 0] " + style 0-7 dim +17| "Model wait 0.0s " + style 0-14 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-blue bold + style 18-31 fg=bright-black + style 34-50 fg=bright-black + style 53-57 fg=bright-black + style 60-69 fg=bright-black +20| " dsh > " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +21-23| diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index fd2437a492..e8df1c98db 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,77 +1,59 @@ -terminal 100x34 buffer=normal length=38 base=4 viewport=4 +terminal 100x34 buffer=normal length=34 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=100 viewportRow=33 bufferRow=37 +cursor hidden column=0 viewportRow=33 bufferRow=33 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 1-60 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +6| "You " + style 0-2 fg=bright-blue bold underline +7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " 8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 fg=bright-black italic -11| -12| " Assistant " - style 1-9 fg=bright-magenta bold -13| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-61 bold -17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-65 fg=bright-black -18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green - style 2-54 dim -19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 0-0 fg=green -20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " - style 0-0 fg=green - style 2-58 fg=red -21| "▌ " - style 0-0 fg=green -22| -23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-62 dim -24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-60 fg=bright-black -25| -26| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-75 fg=yellow -27| -28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -29| -30| " " -31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 0-81 fg=green +10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=cyan +11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-52 dim +12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " + style 0-56 fg=red +14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " + style 0-46 dim +15| +16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim +17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-59 fg=bright-black +18| +19| "Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-74 fg=yellow +20| +21| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-62 fg=red +22-23| +24| "Plan" + style 0-3 fg=bright-blue bold +25| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +26| " " +27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 fg=bright-black -32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -33| " " -34| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " +28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +29| " " +30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " style 2-65 fg=bright-blue bold style 67-97 fg=bright-black -35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " - style 2-64 dim -36| " " -37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 73-99 dim +31| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +32| " " +33| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index a507db017a..f349b0113c 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -27,6 +27,8 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ 'conversation-streaming', + 'shell-prompt-multiline', + 'step-timing-completed', 'retry-scheduled', 'retry-recovered', 'retry-cancelled', @@ -40,12 +42,12 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'untrusted-controls', 'question-dialog', + 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', 'model-selector', - 'model-effort-switching', 'model-switching', 'errors-and-help', 'disposed-terminal', @@ -104,7 +106,7 @@ async function setupSnapshot( cwd: options.cwd === undefined ? '/workspace/project' : options.cwd, config: Object.assign({ welcome: 'Snapshot agent ready.', - color: true, + theme: { color: true }, title: 'DSH snapshot', }, options.config), }) @@ -195,13 +197,12 @@ const ADVANCED_CARD_TOOLS: Record = { ), edit: visualTool( 'edit', - () => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + // The real edit/write tools produce exactly one diff whose path the title + // already names, so the card omits the redundant per-file header. (): ToolResultView => ({ card: 'diff', - diffs: [ - { path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }, - { path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' }, - ], + diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), subagent: visualTool('subagent', args => ({ @@ -209,12 +210,19 @@ const ADVANCED_CARD_TOOLS: Record = { title: 'Delegate renderer audit', rawInput: (args as { prompt: string }).prompt, })), - task_output: visualTool('task_output', args => ({ - card: 'generic', - kind: 'read', - title: `Read output from background task ${(args as { task_id: string }).task_id}`, - rawInput: (args as { task_id: string }).task_id, - })), + task_output: visualTool( + 'task_output', + args => ({ + card: 'generic', + kind: 'read', + title: `Read output from background task ${(args as { task_id: string }).task_id}`, + rawInput: (args as { task_id: string }).task_id, + }), + () => ({ + card: 'generic', + content: [{ type: 'text', text: '```console\nstarted background task bash-5\n```' }], + }), + ), skill: visualTool('skill', args => ({ card: 'generic', kind: 'read', @@ -228,46 +236,64 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7 describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { + let clock = new Date(2026, 6, 21, 14, 30, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const harness = await setupSnapshot() - // Freeze the loader's first animation interval so this semantic snapshot - // cannot select a different spinner frame under scheduler contention. - const frozenLoaderTimer = setInterval(() => {}, 60_000) - const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer) - try { - await renderAfter(harness, () => { - harness.agent.status = 'running' - harness.ctx.emit('agent/status', harness.agent, 'running') - appendUser(harness.session, 'Show the live update.') - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'block-start', index: 1, blockType: 'text' }, - }) - harness.session.append('assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, - }) + await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') + appendUser(harness.session, 'Show the live update.') + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, }) - const loaderIntervalMs = intervals.mock.calls[0]?.[1] - if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval') - await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5)) - await checkpoint('conversation-streaming', harness.terminal) - } finally { - intervals.mockRestore() - clearInterval(frozenLoaderTimer) - await disposeSnapshot(harness) - } + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…\n\n```ts\nconst visible = true\n```' }, + }) + }) + await checkpoint('conversation-streaming', harness.terminal) + await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins a completed step timing summary', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const harness = await setupSnapshot() + await renderAfter(harness, () => { + clock += 1_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'Checking the result.' }, + }) + clock += 2_000 + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 1, text: 'The result is ready.' }, + }) + clock += 3_000 + harness.session.append('step/end', { turn: 1, step: 1 }) + }) + await checkpoint('step-timing-completed', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) }) it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { @@ -290,15 +316,13 @@ describe('TUI terminal-state snapshots', () => { }) await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { - harness.session.append('assistant/message', { - turn: 1, - step: 2, - provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, - content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], - }, { surfaceOp: 'append' }) - harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - }) + harness.session.append('assistant/message', { + turn: 1, + step: 2, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], + }, { surfaceOp: 'append' }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) @@ -347,7 +371,7 @@ describe('TUI terminal-state snapshots', () => { }) it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => { - const harness = await setupSnapshot({ config: { truecolor: true } }) + const harness = await setupSnapshot({ config: { theme: { truecolor: true } } }) await checkpoint('banner-gradient', harness.terminal, {}, true) await disposeSnapshot(harness) }) @@ -452,6 +476,7 @@ describe('TUI terminal-state snapshots', () => { }) it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { unsafe: visualTool( 'unsafe', @@ -518,14 +543,13 @@ describe('TUI terminal-state snapshots', () => { }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await harness.terminal.waitForFrame(beforeQuestion) - await renderAfter(harness, () => { - agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) - }) + agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) controller.abort() await rejected await disposeSnapshot(harness) + nowSpy.mockRestore() }) it('pins a constrained multi-select question and its validation state', async () => { @@ -568,7 +592,37 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins a single-option question', async () => { + const harness = await setupSnapshot({ + config: { + questionDialogWidth: 200, + questionDialogMaxHeight: 16, + }, + }, { columns: 56, rows: 20 }) + const controller = new AbortController() + const beforeQuestion = harness.terminal.frames + const answer = harness.ctx.userInteraction.ask({ + questions: [{ + id: 'confirm', + header: 'Confirm', + question: 'Continue with this change?', + options: [{ label: 'Proceed', description: 'Apply the proposed change' }], + }], + signal: controller.signal, + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await harness.terminal.waitForFrame(beforeQuestion) + await checkpoint('question-dialog-single-option', harness.terminal) + controller.abort() + await rejected + await disposeSnapshot(harness) + }) + it('pins compaction surface replacement and narrow-to-wide reflow', async () => { + // Freeze the clock: the timing header hides zero-duration buckets, so a + // real-clock millisecond tick between the fixture appends and the render + // would flip `Tools 0.0s` in and out of the pinned header. + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime()) let replacementStart = 0 let replacementEnd = 0 let replacementSources: number[] = [] @@ -602,8 +656,11 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.session.append('user/message', { - content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], - source: { kind: 'plugin', plugin: 'compact' }, + content: [{ + type: 'text', + text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n', + }], + source: { kind: 'plugin', plugin: 'workspace-context' }, }, { surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd }, sourceEventSeqs: replacementSources, @@ -615,9 +672,22 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.terminal.resize(104, 30) }) await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + nowSpy.mockRestore() + }) + + it('pins wrapped and explicit multiline shell-prompt input', async () => { + const harness = await setupSnapshot({}, { columns: 44, rows: 18 }) + await renderAfter(harness, () => { + harness.terminal.send('Explain this implementation with enough detail to wrap across multiple full-width continuation rows without leaving a prompt-sized gap at the right edge.') + harness.terminal.send('\x1b[13;2u') + harness.terminal.send('Then suggest a simpler version.') + }) + await checkpoint('shell-prompt-multiline', harness.terminal) + await disposeSnapshot(harness) }) it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 5, 0).getTime()) const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/help') @@ -635,6 +705,12 @@ describe('TUI terminal-state snapshots', () => { turn: 2, reason: { kind: 'interrupted' }, }) + harness.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) + harness.session.append('turn/end', { turn: 3, reason: { kind: 'disposed' } }) + harness.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) + // A merge-extensible turn-end kind unknown to the TUI still surfaces its + // name so the agent never stops without a visible reason. + harness.session.append('turn/end', { turn: 4, reason: { kind: 'plugin-policy' } as never }) }) await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true }) @@ -643,31 +719,11 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true }) await harness.ctx.fiber.dispose() await harness.terminal.dispose() + nowSpy.mockRestore() }) - it('pins the model selector, effort cycling, and provider-default selection', async () => { - const harness = await setupSnapshot({ - catalog: { - providers: [{ id: 'deepseek', name: 'DeepSeek' }], - models: [ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }, - ], - resolveModelInfo: (_provider, model) => Promise.resolve({ - context: { contextWindow: 128_000 }, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, - ], - ...model === 'deepseek-v4-flash' - ? { defaultEffort: ReasoningEffortId('high') } - : {}, - }, - }), - }, - }, { columns: 92, rows: 32 }) + it('pins the model selector and selection notice', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) await renderAfter(harness, () => { harness.terminal.send('/model') harness.terminal.send('\r') @@ -675,13 +731,6 @@ describe('TUI terminal-state snapshots', () => { await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) await renderAfter(harness, () => { harness.terminal.send('\x1b[B') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - harness.terminal.send('\x1b[Z') - }) - await checkpoint('model-effort-switching', harness.terminal, { includeScrollback: true }) - await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) @@ -727,6 +776,22 @@ describe('TUI terminal-state snapshots', () => { contextWindow: 128_000, contextTokens: 42_000, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', + description: 'Read a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + write: { + name: 'write', + description: 'Write a file', + parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, + execute: async () => null, + }, + }, beforeMount(session) { appendUser(session, 'inspect this session') appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b59054659f..827bc0e6d8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,14 +3,13 @@ import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' +import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' +import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId, type LlmCallConfig, type LlmModelReasoningInfo, } from '@deepseek-ai/dsh-llm' -import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' @@ -25,6 +24,7 @@ import { FILE_REFERENCE_PROMPT, mountTui, renderSkillInvocation, + TuiPromptService, resolveTuiConfig, type TuiOverlayHost, type TuiOverlaySession, @@ -171,8 +171,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], showHardwareCursor: false, - color: true, - truecolor: false, + theme: { + color: true, + truecolor: false, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DeepSeek Harness', }) expect(resolveTuiConfig({ @@ -189,8 +195,7 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { color: false, truecolor: true }, title: 'DSH', })).toEqual({ showReasoning: false, @@ -206,8 +211,14 @@ describe('TUI config', () => { fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, - color: false, - truecolor: true, + theme: { + color: false, + truecolor: true, + leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}', + rightPrompt: '${timing}', + inputPrompt: '${symbol} ${indicator}', + inputPlaceholder: 'press enter to steer and esc to cancel', + }, title: 'DSH', }) }) @@ -1065,40 +1076,6 @@ describe('resume command and /resume', () => { }) describe('pi-tui chat lifecycle and transcript', () => { - it('restores durable goal phase without implying automatic continuation', async () => { - const change: GoalSnapshotChangeMeta = { - kind: 'goal/change', - version: GOAL_CHANGE_VERSION, - operation: 'create', - goal: { - id: GoalId('restored-goal'), - revision: 1, - objective: 'Resume only with human confirmation', - phase: 'active', - maxGoalRounds: 4, - }, - roundsStarted: 0, - createdAt: 10, - updatedAt: 10, - } - const result = await setup({ - beforeMount(session) { - session.append('user/message', { - content: renderGoalChange(change), - source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, - meta: change as unknown as JsonValue, - }, { surfaceOp: 'append' }) - }, - }) - expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') - expect(result.terminal.output).toContain('/goal resume') - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('goal active') - await dispose(result) - }) - it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ // A fixed short cwd keeps the footer's token counters inside the 88-column @@ -1164,10 +1141,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored thought') expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') - expect(result.terminal.output).toContain('↑1.3k ↓42') - // Exact model resolution is async; settle before reading. + expect(result.terminal.output).toContain('/opt (tui-staging) deepseek-v4-flash ↑1.3k ↓42') + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toContain('main-session deepseek-v4-flash') + // Context resolution is async (resolveModelContext); settle before reading. await tick() - expect(result.terminal.output).toContain('42% context tools:collapsed') + expect(result.terminal.output).toContain('42% context') + expect(result.terminal.output).not.toContain('tools:collapsed') // Narrow terminals clip the right-hand context/tools segment first; the // model-led left segment stays. result.terminal.resize(52) @@ -1182,11 +1162,16 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) - result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' }) - // A non-plugin injected source (goal) has no `plugin` field, so its context - // card label falls back to the source kind. - result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n' }], + source: { kind: 'plugin', plugin: 'workspace-context' }, + }, { surfaceOp: 'append' }) + result.session.append('user/message', { + content: [{ type: 'text', text: '' }], + source: { kind: 'plugin', plugin: 'workspace-control-context' }, + }, { surfaceOp: 'append' }) + result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) appendAssistant(result.session, []) result.session.append('step/end', { turn: 1, step: 1 }) @@ -1264,10 +1249,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('final live answer') }) - expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') - expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind + expect(result.terminal.output).toContain('Context · workspace-context') + expect(result.terminal.output).toContain('system-reminder') + expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md') + expect(result.terminal.output).not.toContain('') + expect(result.terminal.output).toContain('\\x9b') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.progress).toContain(true) @@ -1333,6 +1322,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' }, }) + result.session.append('step/end', { turn: 1, step: 1 }) result.session.append('llm/retry', { turn: 1, step: 1, @@ -1353,16 +1343,28 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited') expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks') + expect(result.terminal.output).not.toContain('discarded partial answer') await dispose(result) }) - it('badges queued steering on the running status line and clears it as each drains', async () => { - // Pin a cwd free of the substring under test; the footer renders the path. + it('badges queued steering on the prompt context timing and clears it as each drains', async () => { + // Pin a cwd free of the substring under test; the prompt context renders the path. const result = await setup({ status: 'running', cwd: '/workspace' }) - // Running with nothing queued: the plain steering hint, no badge. - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + // Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint. + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') + result.terminal.output = '' + result.terminal.send('x') + await tick() + expect(result.terminal.output).not.toContain('press enter to steer and esc to cancel') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + const queueSteering = (text: string): void => { result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) } @@ -1371,7 +1373,7 @@ describe('pi-tui chat lifecycle and transcript', () => { } // A steering queue for a different agent never touches this status line. - const other = { ...result.agent, id: SessionId('other') } as unknown as Agent + const other = { ...result.agent, id: SessionId('other') } as Agent result.terminal.output = '' result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) await tick() @@ -1382,7 +1384,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' queueSteering('second') await tick() - expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('2 queued') // A non-steering queue (an idle-style send) leaves the badge untouched. result.terminal.output = '' @@ -1396,7 +1398,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' drainSteering('second') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') // A drain with no matching queued entry is ignored rather than underflowing. @@ -1406,9 +1409,8 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A steering/message whose source matches no pending badge entry (here a - // plugin source with no tracked enqueue) pops nothing, so it cannot consume - // a pending user slot even when it drains first. + // A loop-authored steering event (plugin source, no matching agent/queued) + // cannot consume a pending user slot, even when it drains first. result.terminal.output = '' result.session.append('steering/message', { turn: 1, @@ -1429,104 +1431,402 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.output = '' result.ctx.emit('agent/status', result.agent, 'running') await tick() - expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels') + expect(result.terminal.output).not.toContain('│') expect(result.terminal.output).not.toContain('queued') await dispose(result) }) - it('derives the fine-grained turn phase from session lifecycle events', async () => { - // A live event before the turn runs has no status controller to move. - const idle = await setup() - // A steering queue arriving while idle has no status line to badge, so the - // refresh is a no-op beyond requesting a render. - idle.ctx.emit('agent/inbox/enqueue', idle.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source: { kind: 'user' }, contexts: [], steering: true, wakeup: true }) - idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(idle.terminal.output).not.toContain('Executing tools') - expect(idle.terminal.output).not.toContain('queued') - await dispose(idle) - + it('accumulates exclusive timing buckets across a multi-step turn', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) const result = await setup({ status: 'running' }) - expect(result.terminal.output).toContain('Waiting for the first token') + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'reconsidering' } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'revised' } }) + clock += 3_000 + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + clock += 4_000 + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } }) + clock += 2_000 + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 3_000 result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } }) + result.session.append('step/end', { turn: 1, step: 2 }) await tick() - expect(result.terminal.output).toContain('Thinking') - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } }) - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } }) - await tick() - expect(result.terminal.output).toContain('Responding') - - result.terminal.output = '' - result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' }) - await tick() - expect(result.terminal.output).toContain('Executing tools') - - // The next step reopens the wait window and resets the executing label. - result.terminal.output = '' - result.session.append('step/start', { turn: 1, step: 1 }) - await tick() - expect(result.terminal.output).toContain('Waiting for the first token') - expect(result.terminal.output).not.toContain('Executing tools') - - await dispose(result) - }) - - it('refreshes the running status elapsed time on its own timer', async () => { - let now = 0 - const intervals = vi.spyOn(globalThis, 'setInterval') - let result: Awaited> | undefined - try { - result = await setup({ status: 'running', now: () => now }) - const refresh = intervals.mock.calls.find(([, interval]) => interval === 1_000)?.[0] - if (typeof refresh !== 'function') throw new Error('TUI did not register its elapsed-status refresh interval') - result.terminal.output = '' - // The loader repaints "0s" until the controller's own interval fires; a - // non-zero elapsed proves the refresh, not just the loader's animation. - now = 1_000 - refresh() - await tick() - expect(result.terminal.output).toContain('Waiting for the first token 1s') - } finally { - if (result !== undefined) await dispose(result) - intervals.mockRestore() - } - }) - - it('shows minutes and seconds once a step passes a minute', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000) - result.terminal.output = '' - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) - await tick() - expect(result.terminal.output).toContain('total 1m') + expect(result.terminal.output).toContain('Model wait 1.0s · Thinking 4.0s · Response 4.0s · Tools 4.0s') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 3.0s · Completed') + expect(result.terminal.output).not.toContain('Thinking 0s') nowSpy.mockRestore() await dispose(result) }) - it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => { - const result = await setup({ status: 'running' }) - const base = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base) - // Advance into `responding`, anchoring the phase clock at `base`. - result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + it('rebuilds used subsecond buckets and the durable local completion time', async () => { + let clock = new Date(2026, 6, 21, 14, 32, 6).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 250 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } }) + clock += 500 + session.append('step/end', { turn: 1, step: 1 }) + clock += 86_400_000 + }, + }) + const completed = 'Model wait 0.2s · Response 0.5s · Completed 2026-07-21 14:32:06' + expect(result.terminal.output).toContain(completed) + + result.terminal.output = '' + appendUser(result.session, 'rebuild the transcript') + result.terminal.resize(result.terminal.columns + 1) + await tick() + expect(result.terminal.output).toContain(completed) + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('renders completion for a step whose opening event is unavailable', async () => { + const result = await setup({ omitInitialLifecycle: true }) + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + expect(result.terminal.output).toContain('Completed ') + expect(result.terminal.output).toContain('Assistant') + expect(result.terminal.output).toContain('Model wait 0.0s · Completed') + await dispose(result) + }) + + it('does not reuse a completed turn before the next turn starts', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + let result: Awaited> | undefined + try { + result = await setup({ + beforeMount(session) { + clock += 2_000 + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } }) + clock += 1_000 + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }, + }) + result.agent.status = 'running' + result.terminal.output = '' + result.ctx.emit('agent/status', result.agent, 'running') + await tick() + expect(result.terminal.output).not.toContain('Model wait') + expect(result.terminal.output).toContain('press enter to steer and esc to cancel') + expect(result.terminal.output).not.toContain('│') + expect(result.terminal.output).not.toContain('Response 1s') + + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + result.session.append('step/start', { turn: 2, step: 1 }) + clock += 1_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'next' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + expect(result.terminal.output).not.toContain('Model wait 3.0s') + } finally { + if (result !== undefined) await dispose(result) + nowSpy.mockRestore() + } + }) + + it('starts a running status before lifecycle events arrive', async () => { + const result = await setup({ status: 'running', omitInitialLifecycle: true }) + expect(result.terminal.output).not.toContain('Model wait') + await dispose(result) + }) + + it('replaces the prompt caret with a phase-specific status glyph while running', async () => { + // Hold the clock past the fade-in so the glyph is at full opacity; with + // color off the settled glyph renders as its bare character. + let clock = 0 + const result = await setup({ status: 'running', now: () => clock }) + clock = 1_000 + + // A space separates `dsh` from the caret slot: the prompt reads + // `dsh ` with the same visible width as the idle `dsh > `, so the + // cursor never shifts. Assert both the glyph slot and that constant width + // (color is off in this harness, so output carries no ANSI to strip). + const promptWidth = (): number => { + const row = result.terminal.output.split('\n').find(line => line.includes('dsh')) + if (row === undefined) throw new Error('prompt row not rendered') + return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6)) + } + + // Each phase swaps only the glyph character in the same slot at equal width. + const phaseGlyph: [() => void, string][] = [ + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '], + [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' } }), 'dsh ● '], + [() => result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }), 'dsh ⚙ '], + ] + let runningWidth: number | undefined + for (const [drive, expected] of phaseGlyph) { + result.terminal.output = '' + drive() + await tick() + expect(result.terminal.output).toContain(expected) + runningWidth ??= promptWidth() + expect(promptWidth()).toBe(runningWidth) + } + + // Idle begins a fade-out; once it settles (clock past the fade window) the + // plain `>` caret returns at the same width — no horizontal shift. The + // fade-out timer emits intermediate frames, so read the terminal's final + // rendered prompt row rather than the accumulated stream. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + clock = 2_000 + await new Promise(resolve => setTimeout(resolve, 150)) + await tick() + const promptRow = (): string => { + const rows = result.terminal.output.split(/\r?\n|\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')) + return rows.at(-1) ?? '' + } + expect(promptRow()).toContain('dsh > ') + expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u) + expect(promptWidth()).toBe(runningWidth) + + await dispose(result) + }) + + // Extract the running glyph's interpolated gray channel from a rendered frame. + const glyphGray = (frame: string): number => { + const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame) + if (m === null) throw new Error('frame did not paint a truecolor glyph') + const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])] + // Pure gray: equal channels, never the blue-dominant accent. + expect(r).toBe(g) + expect(g).toBe(b) + return r + } + + it('throbs the running glyph in dim gray, swelling from invisible to full, never accent', async () => { + let clock = 0 + let chunkIndex = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + // A fresh chunk index each frame changes the streamed line, forcing the + // diffing terminal to repaint the prompt row and re-emit the glyph slot. + const frameAt = async (t: number): Promise => { + clock = t + chunkIndex += 1 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: chunkIndex, text: '.' } }) + await tick() + return result.terminal.output + } + + // The pulse swells from fully invisible at its trough to the settled peak + // and back. At phase 0 (t=1400, pulse level 0, past fade-in) the glyph is + // hidden — the slot carries no ● — so the dimmest breath truly disappears. + const trough = await frameAt(1_400) + expect(trough).not.toMatch(/●/u) + // Half a period later (t=2100, pulse peak) it paints the brightest gray. + const peak = await frameAt(2_100) + expect(glyphGray(peak)).toBe(136) + // A frame partway up the swell paints a gray strictly between trough and + // full, and the glyph is never the accent color. + const rising = await frameAt(1_680) + const grey = glyphGray(rising) + expect(grey).toBeGreaterThan(43) + expect(grey).toBeLessThan(136) + expect(rising).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('fades the running glyph out to the plain caret after the turn ends', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) + clock = 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) await tick() - // Four seconds later the terminal reports a light color scheme, rebuilding - // the status loader; the phase and its elapsed time must survive the rebuild. - nowSpy.mockReturnValue(base + 4_000) + // End the turn: the last glyph fades out over 300 ms rather than vanishing. + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + // Just after the end the glyph still paints a gray, not `>`. + expect(result.terminal.output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m●/u) + expect(result.terminal.output).not.toContain('dsh \x1b[90m>') + + // While the clock stays within the fade window the timer keeps ticking + // without clearing the fade (the not-yet-elapsed branch): the last frame is + // still the fading glyph, and the plain caret has not returned. + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + const lastPromptRow = result.terminal.output.split(/\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')).at(-1) ?? '' + expect(lastPromptRow).not.toContain('dsh \x1b[90m>') + + // Past the fade window the fade timer clears and the plain caret returns. + clock = 2_000 + result.terminal.output = '' + await new Promise(resolve => setTimeout(resolve, 120)) + await tick() + expect(result.terminal.output).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + expect(result.terminal.output).toContain('>') + + await dispose(result) + }) + + it('appears past the fade midpoint and disappears without truecolor, still dim not accent', async () => { + let clock = 0 + const result = await setup({ status: 'running', config: { theme: { color: true } }, now: () => clock }) + const frameAt = async (t: number): Promise => { + clock = t + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } }) + await tick() + return result.terminal.output + } + + // Without truecolor there is no per-frame gray: below the fade midpoint the + // glyph slot is blank; past it the glyph shows in the palette muted role + // (ANSI 90), never the accent (SGR 94). + const early = await frameAt(60) + expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u) + const shown = await frameAt(300) + expect(shown).toMatch(/\x1b\[90m●/u) + expect(shown).not.toMatch(/\x1b\[94m●/u) + + await dispose(result) + }) + + it('shows the plain prompt caret while idle', async () => { + const result = await setup({ now: () => 0 }) + expect(result.terminal.output).toContain('dsh > ') + expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u) + await dispose(result) + }) + + it('escapes configured prompt controls while preserving registry-owned styling', async () => { + const result = await setup({ config: { theme: { leftPrompt: 'LEFT\u001B]2;unsafe\u0007 ${custom}' } } }) + result.ctx.tuiPrompt.register('custom', '\u001B[1mTRUSTED\u001B[22m') + await tick() + expect(result.terminal.output).toContain('LEFT\\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('\u001B[1mTRUSTED\u001B[22m') + expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + await dispose(result) + }) + + it('redraws when an out-of-band prompt value changes on its own schedule', async () => { + // A plugin-owned value that changes without any other UI event must still + // repaint: the registry notifies the renderer through its subscription. + const result = await setup({ config: { theme: { leftPrompt: '${custom}${model}' } } }) + const handle = result.ctx.tuiPrompt.register('custom', 'BEFORE ') + await tick() + expect(result.terminal.output).toContain('BEFORE ') + + result.terminal.output = '' + handle.set('AFTER ') + // The coalesced notification lands on a microtask; no session/agent event fires. + await tick() + expect(result.terminal.output).toContain('AFTER ') + expect(result.terminal.output).not.toContain('BEFORE ') + await dispose(result) + }) + + it('tracks steering drains without a running status line', async () => { + const result = await setup() + const source = { kind: 'user' as const } + result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source, contexts: [], steering: true, wakeup: true }) + result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' }) + await tick() + expect(result.terminal.output).not.toContain('queued') + await dispose(result) + }) + + it('refreshes the running turn timing on its own timer', async () => { + let now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + const intervals = vi.spyOn(globalThis, 'setInterval') + let result: Awaited> | undefined + try { + result = await setup({ status: 'running', now: () => now }) + // The running prompt animates at ~20 fps (50 ms); the same tick keeps the + // elapsed timing text current, so no separate timing-only timer exists. + const refresh = intervals.mock.calls.find(([, interval]) => interval === 50)?.[0] + if (typeof refresh !== 'function') throw new Error('TUI did not register its running-status refresh interval') + result.terminal.output = '' + now += 1_000 + refresh() + await tick() + expect(result.terminal.output).toContain('Model wait 1.0s') + } finally { + if (result !== undefined) await dispose(result) + intervals.mockRestore() + nowSpy.mockRestore() + } + }) + + it('shows minutes and seconds in accumulated timing', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 95_000 + result.terminal.output = '' + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } }) + await tick() + expect(result.terminal.output).toContain('Model wait 1m35.0s') + nowSpy.mockRestore() + await dispose(result) + }) + + it('trails the completed step timing below the step tool cards, not above them', async () => { + const clock = new Date(2026, 6, 21, 12, 0, 0).getTime() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(clock) + const result = await setup({ status: 'running' }) + // A step whose assistant message drives a tool call: the tool card is + // appended after the assistant text, so the timing footer must follow the + // tool output rather than sit above it (its first message). + appendAssistant(result.session, [ + { type: 'text', text: 'Running a command' }, + { type: 'tool-call', id: 'c1' as never, name: 'bash', arguments: '{}' }, + ]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false, + }, { surfaceOp: 'append' }) + result.terminal.output = '' + result.session.append('step/end', { turn: 1, step: 1 }) + await tick() + + const frame = result.terminal.output + const toolAt = frame.indexOf('command output') + const timingAt = frame.indexOf('Completed 2026-07-21 12:00:00') + expect(toolAt).toBeGreaterThanOrEqual(0) + expect(timingAt).toBeGreaterThan(toolAt) + nowSpy.mockRestore() + await dispose(result) + }) + + it('preserves accumulated timing across a mid-turn color-scheme change', async () => { + let clock = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock) + const result = await setup({ status: 'running' }) + clock += 1_000 + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answering' } }) + clock += 4_000 result.terminal.output = '' result.terminal.send('\x1b[?997;2n') await tick() await tick() - expect(result.terminal.output).toContain('Responding 4s') - expect(result.terminal.output).not.toContain('Waiting for the first token') + expect(result.terminal.output).toContain('Model wait 1.0s · Response 4.0s') nowSpy.mockRestore() await dispose(result) @@ -1535,7 +1835,7 @@ describe('pi-tui chat lifecycle and transcript', () => { it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ cwd: '/workspace', - config: { color: true }, + config: { theme: { color: true } }, beforeMount(session) { session.append('user/message', { content: [ @@ -1549,7 +1849,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, { surfaceOp: 'append' }) appendAssistant(session, [ { type: 'reasoning', text: 'styled reasoning' }, - { type: 'text', text: 'styled answer' }, + { type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' }, ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) session.append('todo/write', { todos: [ { content: 'done', status: 'completed' }, @@ -1571,6 +1871,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('nested result') expect(result.terminal.output).toContain('[future-block]') expect(result.terminal.output).toContain('[content]') + expect(result.terminal.output).toContain('\x1b[36mconst answer = 42\x1b[39m') + expect(result.terminal.output).not.toContain('```') expect(result.terminal.output).toContain('↑2.0m ↓1.5m') await dispose(result) }) @@ -1608,7 +1910,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }, }) await vi.waitFor(() => { - expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k') + expect(homeResult.terminal.output).toContain('~ (tui-staging) deepseek-v4-flash ↑25k ↓10k') }) await dispose(homeResult) @@ -1692,6 +1994,16 @@ describe('pi-tui chat lifecycle and transcript', () => { contextTokens: 42_000, config: { showReasoning: false }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + tools: { + read: { + name: 'read', description: 'Read a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + write: { + name: 'write', description: 'Write a file', parameters: {}, + output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, + }, + }, beforeMount(session) { session.append('session/title', { title: 'Inspect status \u001B]2;unsafe\u0007', @@ -1712,11 +2024,18 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) + result.ctx.systemPrompt.section({ + name: 'test:status', + order: 1, + text: 'Current instructions \u001B]2;prompt-unsafe\u0007', + }) result.agent.status = 'running' agentEvents(result.ctx, result.agent).emit('agent/status', 'running') result.terminal.send('/status') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Session status') + }) expect(result.terminal.output).toContain('Session status') expect(result.terminal.output).toContain('main-session') @@ -1729,6 +2048,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)') expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC') + expect(result.terminal.output).toContain('System prompt') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('Current instructions \\x1b]2;prompt-unsafe\\x07') + expect(result.terminal.output).toContain('Registered tools') + expect(result.terminal.output).toContain('read, write') expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') result.terminal.resize(56) @@ -1764,10 +2088,22 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('n/a (0 read + 0 write)') expect(result.terminal.output).toContain('7 used · capacity unknown') expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC') + expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(result.terminal.output).toContain('(none)') await dispose(result) dateNow.mockRestore() }) + it('/quit exits while idle', async () => { + const result = await setup() + result.terminal.send('/quit') + result.terminal.send('\r') + await tick() + + expect(result.exit).toHaveBeenCalledWith(0) + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() @@ -1888,7 +2224,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await mkdir(join(cwd, 'docs'), { recursive: true }) await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') - await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n') + await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') const result = await setup({ cwd, tools: { @@ -1925,12 +2261,13 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Folder · docs/') }) result.terminal.send('\t') - result.terminal.output = '' + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · design notes.md') + }) result.terminal.send('\t') await vi.waitFor(() => { expect(result.terminal.output).toContain('@"docs/design notes.md"') }) - await tick() result.terminal.send('\r') await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) @@ -2359,14 +2696,12 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') - const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') - }) + await tick() + expect(result.terminal.output).toContain('Select model') result.terminal.send('\x1b') await tick() @@ -2457,16 +2792,13 @@ describe('pi-tui chat lifecycle and transcript', () => { )).resolves.toEqual({ provider: 'beta', model: 'shared' }) result.agent.status = 'running' - const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - const output = result.terminal.output.slice(runningSelectorOutput) - expect(output).toContain('Select model') - expect(output).toContain('alpha/a1') - expect(output).toContain('Alpha One — Fast — Low — current') - expect(output).toContain('Beta One — High') - }) + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — Low — current') + expect(result.terminal.output).toContain('Beta One — High') result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\x1b[Z') @@ -2480,14 +2812,11 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.agent.steered).toEqual([]) initialContext.resolve({ contextWindow: 100 }) await tick() - expect(result.terminal.output).not.toContain('50% context tools:collapsed') + expect(result.terminal.output).not.toContain('50% context') - const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await vi.waitFor(() => { - expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') - }) + await tick() result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') @@ -2495,7 +2824,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.emit('agent/status', result.agent, 'idle') await tick() expect(result.terminal.output).toContain('b1 max ') - expect(result.terminal.output).toContain('25% context tools:collapsed') + expect(result.terminal.output).toContain('25% context') + expect(result.terminal.output).not.toContain('tools:collapsed') result.terminal.send('/status') result.terminal.send('\r') await tick() @@ -2559,7 +2889,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }) }, }) - expect(resumedDefault.terminal.output).toContain('default • main-session') + expect(resumedDefault.terminal.output).toContain('default ↑0 ↓0') await dispose(resumedDefault) const unset = await setup({ @@ -2791,9 +3121,9 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.fiber.dispose() }) - it('cancels before /exit while running and handles agent errors/disposal', async () => { + it('cancels before /quit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) - result.terminal.send('/exit') + result.terminal.send('/quit') result.terminal.send('\r') await tick() expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) @@ -2801,7 +3131,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const events = await setup() const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) - const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } as unknown as Agent + const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession } unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running') @@ -2825,6 +3155,11 @@ describe('pi-tui chat lifecycle and transcript', () => { turn: 7, reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, }) + events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } }) + events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } }) + events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) + // Merge-extensible reason kind unknown to the TUI still names the stop. + events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never }) agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') @@ -2834,6 +3169,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('Turn rejected') expect(events.terminal.output).toContain('previous process ended') + expect(events.terminal.output).toContain('Turn stopped: the agent was disposed') + expect(events.terminal.output).toContain('Turn ended: plugin-policy') expect(events.terminal.output).toContain('was disposed') await dispose(events) }) @@ -2846,14 +3183,19 @@ describe('skill slash command', () => { const skills = ctx.get('skills') if (skills === undefined) throw new Error('skills service not mounted') skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' }) + skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' }) skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true }) } - it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => { + it('labels slash completions by scope and hides model-disabled skills', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill') await tick() expect(result.terminal.output).toContain('demo-skill') + expect(result.terminal.output).toContain('(user)') + expect(result.terminal.output).toContain('project-skill') + expect(result.terminal.output).toContain('(project)') + expect(result.terminal.output).not.toContain('[instructions]') expect(result.terminal.output).not.toContain('hidden-skill') await dispose(result) }) @@ -3010,10 +3352,22 @@ describe('tool cards and surface replay', () => { }), presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }), }, + singleDiff: { + name: 'singleDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit src/only.ts', + diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), - presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }), + presentResult: () => ({ + card: 'generic', + title: 'Inspected', + content: [{ type: 'text', text: 'result **text**\n\n```console\nstarted background task bash-5\n```' }], + }), }, throwing: { name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], @@ -3024,6 +3378,24 @@ describe('tool cards and surface replay', () => { name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'terminal', title: 'raw command' }), }, + // An empty-string description is treated as no description: the header omits + // the ` / ` segment, exactly as an absent description does. + emptyDescTerminal: { + name: 'emptyDescTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'blank desc command', description: '' }), + }, + // A generic card whose title only repeats the tool name and carries no + // content or rawInput renders a header with an empty body block. + emptyBody: { + name: 'emptyBody', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'emptyBody' }), + }, + multilineTerminal: { + name: 'multilineTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // A multi-line bash command as the title/description: the card title and the + // meta rows are single logical lines and must render inline, not break rows. + presentCall: () => ({ card: 'terminal', title: 'S=/tmp\necho "$S"', description: 'set\nand echo' }), + }, undefinedViews: { name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => undefined, @@ -3042,6 +3414,10 @@ describe('tool cards and surface replay', () => { name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), }, + knownXml: { + name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Known XML' }), + }, } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { @@ -3054,10 +3430,14 @@ describe('tool cards and surface replay', () => { ['c5', 'throwing', '{}'], ['c6', 'unknown', 'not-json'], ['c7', 'rawTerminal', '{"value":"raw"}'], + ['c14', 'emptyDescTerminal', '{}'], + ['c15', 'emptyBody', '{}'], + ['c9', 'multilineTerminal', '{}'], ['c8', 'undefinedViews', '{"value":8}'], ['c10', 'empty', '{}'], ['c11', 'terminalResult', '{}'], ['c12', 'symbolic', '{}'], + ['c13', 'knownXml', '{}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -3106,11 +3486,16 @@ describe('tool cards and surface replay', () => { result.session.append('tool/result', { turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, callId: 'c13' as never, + content: [{ type: 'text', text: 'literal' }], + isError: false, + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, callId: 'orphan' as never, - content: [{ type: 'text', text: 'orphan result' }], + content: [{ type: 'text', text: '/tmp/a.txthelloworld' }], isError: true, error: { name: 'InterruptedError', code: 'interrupted' }, }, { surfaceOp: 'append' }) @@ -3119,11 +3504,32 @@ describe('tool cards and surface replay', () => { const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') + // A multi-line terminal title and description render inline (newline escaped + // to `\x0a`), so they cannot break onto extra rows and collide with the body. + expect(output).toContain('S=/tmp\\x0aecho "$S"') + expect(output).toContain('set\\x0aand echo') expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') - expect(output).toContain('Edit files') + // The header is a fixed `Tool / ` frame; the tool name shows there. + expect(output).toContain('Tool / bash') + expect(output).toContain('Tool / edit') + // An empty-string terminal description contributes no ` / ` segment; + // the header ends at the tool name, and the command shows as the body $-line. + expect(output).toContain('Tool / emptyDescTerminal') + expect(output).not.toContain('Tool / emptyDescTerminal /') + expect(output).toContain('$ blank desc command') + // A card whose title only repeats the name renders header-only (empty body). + expect(output).toContain('Tool / emptyBody') + // A diff card drops its title (the paths + change footer carry the meaning). + // The first file's path is head-visible; the second file and the change + // footer sit past this card's 4-line budget and appear only when expanded. + expect(output).not.toContain('Edit files') + expect(output).toContain('a.txt') + // A generic card's presenter title moves from the header into the body. expect(output).toContain('Inspected') expect(output).toContain('result text') + expect(output).toContain('started background task bash-5') + expect(output).not.toContain('```console') expect(output).toContain('Presenter failed') expect(output).toContain('not-json') expect(output).toContain('nested output') @@ -3131,7 +3537,10 @@ describe('tool cards and surface replay', () => { expect(output).toContain('undefined presenter output') expect(output).toContain('Empty card') expect(output).toContain('converted terminal') - expect(output).toContain('orphan result') + expect(output).toContain('literal') + expect(output).toContain('path: /tmp/a.txt') + expect(output).toContain('line (number="1"): hello') + expect(output).not.toContain('') result.terminal.send('/redraw') result.terminal.send('\r') @@ -3140,11 +3549,41 @@ describe('tool cards and surface replay', () => { expect(collapsed).toContain('Run command') expect(collapsed).toContain('[exit 0]') expect(collapsed).not.toContain('▌ hello') - expect(collapsed).not.toContain('world') + expect(collapsed).not.toContain('▌ world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') + expect(result.terminal.output).toContain('Tool cards expanded.') + expect(result.terminal.output).not.toContain('tools:expanded') expect(result.terminal.output).toContain('+ created') + expect(result.terminal.output).toContain('console') + // The multi-file diff's second-file change and its footer surface once + // expanded (`+ after` is b.txt's new text; the footer counts both files). + expect(result.terminal.output).toContain('+ after') + expect(result.terminal.output).toContain('· 2 files') + await dispose(result) + }) + + it('names a single-file diff in the body once, under a fixed Tool header', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'edit one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'single' as never, name: 'singleDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'single' as never, name: 'singleDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // The header is a fixed `Tool / ` frame; the diff title is dropped and + // the file path shows once in the body, above the change footer. + expect(output).toContain('Tool / singleDiff') + expect(output).not.toContain('Edit src/only.ts') + expect(output.split('src/only.ts').length - 1).toBe(1) + expect(output).toContain('- old') + expect(output).toContain('+ new') + expect(output).toContain('· 1 file') await dispose(result) }) @@ -3215,6 +3654,8 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() + const singleOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('Choose or type')) + expect(singleOptionRender).not.toContain('↑/↓ navigate') result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') @@ -3232,7 +3673,7 @@ describe('TUI user-interaction dialogs', () => { }) it('handles option wrapping, deselection errors, and returning from custom input', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) const single = result.ctx.userInteraction.ask({ questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }], }) @@ -3456,7 +3897,7 @@ describe('TUI extension service', () => { const secondTerminal = new FakeTerminal() const secondController = createTuiChat(result.ctx, { sessionId: result.agent.id, - color: false, + theme: { color: false }, welcome: 'Mounted again.', }, { terminal: secondTerminal, @@ -3481,6 +3922,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3488,7 +3930,7 @@ describe('terminal mounting', () => { followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() - mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) await tick() expect(terminal.started).toBe(1) await ctx.fiber.dispose() @@ -3505,6 +3947,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ @@ -3514,9 +3957,9 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). await ctx.plugin({ - inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'], + inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter', 'tuiPrompt'], apply: (pluginCtx: Context) => { - mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() }) + mountTui(pluginCtx, { theme: { color: false } }, { terminal, exit: vi.fn() }) }, }) await tick() @@ -3535,9 +3978,10 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() - mountTui(ctx, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() }) + mountTui(ctx, { sessionId: 'late-session', theme: { color: false } }, { terminal, exit: vi.fn() }) expect(terminal.started).toBe(0) const otherSession = ctx.sessions.create(SessionId('other-session')) @@ -3565,10 +4009,11 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed')) expect(terminal.output).toBe('') @@ -3594,11 +4039,12 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() const exit = vi.fn() - mountTui(ctx, { sessionId: 'main-session', color: false }, { terminal, exit }) + mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit }) ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), { toString(): string { throw new Error('coercion failed') }, }) @@ -3616,6 +4062,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -3627,7 +4074,7 @@ describe('terminal mounting', () => { const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } - expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) + expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', theme: { color: false } }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') await tick() expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) @@ -3652,6 +4099,7 @@ describe('terminal mounting', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running') @@ -3659,9 +4107,9 @@ describe('terminal mounting', () => { }) it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => { - const result = await setup({ config: { color: true } }) + const result = await setup({ config: { theme: { color: true } } }) // Initial render uses dark-optimised palette: SGR 2 (dim) for dim text. - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') // A report matching the current scheme is a no-op: no palette rebuild or // re-render (ESC [?997;1n = dark, the startup default). @@ -3670,7 +4118,8 @@ describe('terminal mounting', () => { await tick() expect(result.terminal.output.length).toBe(beforeSameScheme) - // ESC [?997;2n reports light; ESC [?997;1n reports dark. + // Simulate the terminal responding with a light color scheme report + // (ESC [?997;2n = light, ESC [?997;1n = dark). result.terminal.send('\x1b[?997;2n') await tick() await tick() @@ -3682,10 +4131,12 @@ describe('terminal mounting', () => { // uses ANSI 90 for the same header text. expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') + // Switch back to dark scheme. result.terminal.send('\x1b[?997;1n') await tick() await tick() - expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + // After switching back, a new write uses SGR 2 for the header detail. + expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash') await dispose(result) }) @@ -3700,11 +4151,12 @@ describe('terminal mounting', () => { } const terminal = new QueryFailTerminal() const result = await createTuiTestHarness(terminal, vi.fn(), { - config: { color: true }, + config: { theme: { color: true } }, cwd: process.cwd(), }) await tick() - expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash') + expect(terminal.output).toContain('\x1b[94m~/') + expect(terminal.output).toContain('\x1b[90m (tui-staging)') await disposeTuiTestHarness(result) }) it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => { @@ -3801,7 +4253,7 @@ describe('banner sweep reveal', () => { // The product name carries a per-letter 24-bit gradient from the brand // indigo to light blue; the per-letter layout is pinned by the // `banner-gradient` terminal snapshot. - const result = await setup({ config: { color: true, truecolor: true } }) + const result = await setup({ config: { theme: { color: true, truecolor: true } } }) expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m') expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m') expect(result.terminal.output).toContain('HARNESS') diff --git a/packages/ui/tui/tests/xml-tool-output.spec.ts b/packages/ui/tui/tests/xml-tool-output.spec.ts new file mode 100644 index 0000000000..df270eed20 --- /dev/null +++ b/packages/ui/tui/tests/xml-tool-output.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { renderUnknownXml } from '../src/xml-tool-output.ts' + +const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml( + source, + limit, + expanded, + text => text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`), + text => `[label]${text}[/label]`, + count => ` … +${count} lines`, +) + +describe('unknown-tool XML rendering', () => { + it('renders nested elements and attributes as an indented tree', () => { + expect(render(` + /tmp/a.txt + file + + hello + world + +`)).toEqual([ + '[label]result[/label]', + ' [label]path:[/label] /tmp/a.txt', + ' [label]type:[/label] file', + ' [label]content[/label]', + ' [label]line (number="1"):[/label] hello', + ' [label]line (number="2"):[/label] world', + ]) + }) + + it('renders root text, CDATA, empty elements, and multiline nested text', () => { + expect(render(' \nfirst\nsecond\n ')).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render('\nfirst\nsecond\n', 1, true)).toEqual([ + '[label]result[/label]', + ' first', + ' second', + ]) + expect(render(']]>')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] literal ', + ' [label]empty[/label]', + ]) + }) + + it('previews each top-level child independently and expands all rows', () => { + const xml = '\na\nb\nc\nd\ne\nf\n\ng\nh\ni\nj\nk\nl\n' + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]first[/label]', + ' a', + ' … +4 lines', + ' f', + ' [label]second[/label]', + ' g', + ' … +4 lines', + ' l', + ]) + expect(render(xml, 3, true)).toHaveLength(15) + }) + + it('bounds the collapsed child count and counts the hidden lines', () => { + const xml = `${Array.from({ length: 8 }, (_, index) => `${index}`).join('')}` + expect(render(xml, 3)).toEqual([ + '[label]result[/label]', + ' [label]item:[/label] 0', + ' [label]item:[/label] 1', + ' … +5 lines', + ' [label]item:[/label] 7', + ]) + expect(render(xml, 3, true)).toHaveLength(9) + }) + + it('escapes control characters expanded from character references', () => { + expect(render('tab csi›')).toEqual([ + '[label]result (attr="a\\\\x9bb")[/label]', + ' tab\\x09csi\\x9b', + ]) + expect(render('')).toEqual([ + '[label]result[/label]', + ' [label]value:[/label] del\\x7f', + ]) + }) + + it.each([ + 'missing close', + '', + ' ', + 'prefix /tmp/a', + '/tmp/a suffix', + '', + '', + '', + '', + '', + ' \n ', + ])('declines malformed or mixed text: %s', (source) => { + expect(render(source)).toBeUndefined() + }) +}) diff --git a/packages/ui/tui/tsdown.config.ts b/packages/ui/tui/tsdown.config.ts new file mode 100644 index 0000000000..4d985b3971 --- /dev/null +++ b/packages/ui/tui/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown' +import baseConfig from '../../../tsdown.config.ts' + +export default defineConfig({ + ...baseConfig, + entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/prompt.js'], +}) diff --git a/patches/@earendil-works__pi-tui@0.80.7.patch b/patches/@earendil-works__pi-tui@0.80.7.patch new file mode 100644 index 0000000000..c14edb5f7e --- /dev/null +++ b/patches/@earendil-works__pi-tui@0.80.7.patch @@ -0,0 +1,346 @@ +diff --git a/dist/components/editor.d.ts b/dist/components/editor.d.ts +index a6fedc9e3b36d066e34860d040db6df47d88c432..f0b20eb87686215d1d1a54274a7b1ebdf4030ef1 100644 +--- a/dist/components/editor.d.ts ++++ b/dist/components/editor.d.ts +@@ -21,7 +21,7 @@ export interface TextChunk { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[]; ++export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[], continuationWidth?: number): TextChunk[]; + export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +@@ -29,6 +29,13 @@ export interface EditorTheme { + export interface EditorOptions { + paddingX?: number; + autocompleteMaxVisible?: number; ++ /** Omit the editor's horizontal frame. */ ++ frame?: "horizontal" | "none"; ++ /** Fixed-width prefixes for the first input row and explicit newlines. Wrapped rows start at the editor edge. */ ++ prompt?: { ++ first: string; ++ continuation: string; ++ }; + } + export declare class Editor implements Component, Focusable { + private state; +@@ -79,6 +86,11 @@ export declare class Editor implements Component, Focusable { + getAutocompleteMaxVisible(): number; + setAutocompleteMaxVisible(maxVisible: number): void; + setAutocompleteProvider(provider: AutocompleteProvider): void; ++ /** Replace fixed-width first and continuation input prefixes. */ ++ setPrompt(prompt: { ++ first: string; ++ continuation: string; ++ }): void; + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. +diff --git a/dist/components/editor.js b/dist/components/editor.js +index 6c03aeec4148571558713e885ac7f7df18a511bc..0111317ccab31a1e973b92272d8a668b75a29174 100644 +--- a/dist/components/editor.js ++++ b/dist/components/editor.js +@@ -79,7 +79,7 @@ function segmentWithMarkers(text, baseSegmenter, validIds) { + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +-export function wordWrapLine(line, maxWidth, preSegmented) { ++export function wordWrapLine(line, maxWidth, preSegmented, continuationWidth = maxWidth) { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } +@@ -90,6 +90,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const chunks = []; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; + let currentWidth = 0; ++ let currentMaxWidth = maxWidth; + let chunkStart = 0; + // Wrap opportunity: the position after the last whitespace before a non-whitespace + // grapheme, i.e. where a line break is allowed. +@@ -102,11 +103,12 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + const charIndex = seg.index; + const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); + // Overflow check before advancing. +- if (currentWidth + gWidth > maxWidth) { +- if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { ++ if (currentWidth + gWidth > currentMaxWidth) { ++ if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= continuationWidth) { + // Backtrack to last wrap opportunity (the remaining content + // plus the current grapheme still fits within maxWidth). + chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = wrapOppIndex; + currentWidth -= wrapOppWidth; + } +@@ -117,22 +119,29 @@ export function wordWrapLine(line, maxWidth, preSegmented) { + // the current grapheme (e.g. a wide character) still exceeds + // maxWidth. + chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex; + currentWidth = 0; + } + wrapOppIndex = -1; + } +- if (gWidth > maxWidth) { +- // Single atomic segment wider than maxWidth (e.g. paste marker ++ if (gWidth > currentMaxWidth) { ++ if (segments.length === 1) { ++ chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length }); ++ return chunks; ++ } ++ // Single atomic segment wider than the current line width (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. +- const subChunks = wordWrapLine(grapheme, maxWidth); ++ const subChunks = wordWrapLine(grapheme, currentMaxWidth, undefined, continuationWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]; + chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); + } + const last = subChunks[subChunks.length - 1]; ++ if (subChunks.length > 1) ++ currentMaxWidth = continuationWidth; + chunkStart = charIndex + last.startIndex; + currentWidth = visibleWidth(last.text); + wrapOppIndex = -1; +@@ -189,8 +198,12 @@ export class Editor { + tui; + theme; + paddingX = 0; ++ frame = "horizontal"; ++ prompt; ++ promptWidth = 0; + // Store last render width for cursor navigation + lastWidth = 80; ++ lastContinuationWidth = 80; + // Vertical scrolling support + scrollOffset = 0; + // Border color (can be changed dynamically) +@@ -243,9 +256,29 @@ export class Editor { + this.borderColor = theme.borderColor; + const paddingX = options.paddingX ?? 0; + this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; ++ this.frame = options.frame ?? "horizontal"; ++ this.prompt = options.prompt; ++ if (this.prompt) { ++ const firstWidth = visibleWidth(this.prompt.first); ++ const continuationWidth = visibleWidth(this.prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.promptWidth = firstWidth; ++ } + const maxVisible = options.autocompleteMaxVisible ?? 5; + this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + } ++ setPrompt(prompt) { ++ const firstWidth = visibleWidth(prompt.first); ++ const continuationWidth = visibleWidth(prompt.continuation); ++ if (firstWidth !== continuationWidth) { ++ throw new Error("Editor prompt prefixes must have equal visible widths"); ++ } ++ this.prompt = prompt; ++ this.promptWidth = firstWidth; ++ this.invalidate(); ++ } + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + validPasteIds() { + return new Set(this.pastes.keys()); +@@ -364,14 +397,17 @@ export class Editor { + const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); + const paddingX = Math.min(this.paddingX, maxPadding); + const contentWidth = Math.max(1, width - paddingX * 2); ++ const inputWidth = Math.max(1, contentWidth - this.promptWidth); + // Layout width: with padding the cursor can overflow into it, + // without padding we reserve 1 column for the cursor. +- const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); +- // Store for cursor navigation (must match wrapping width) ++ const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1)); ++ const continuationLayoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); ++ // Store for cursor navigation (must match wrapping widths) + this.lastWidth = layoutWidth; ++ this.lastContinuationWidth = continuationLayoutWidth; + const horizontal = this.borderColor("─"); + // Layout the text +- const layoutLines = this.layoutText(layoutWidth); ++ const layoutLines = this.layoutText(layoutWidth, continuationLayoutWidth); + // Calculate max visible lines: 30% of terminal height, minimum 5 lines + const terminalRows = this.tui.terminal.rows; + const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); +@@ -396,16 +432,22 @@ export class Editor { + const rightPadding = leftPadding; + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { +- const indicator = `─── ↑ ${this.scrollOffset} more `; +- const remaining = width - visibleWidth(indicator); +- if (remaining >= 0) { +- result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↑ ${this.scrollOffset} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); + } + else { +- result.push(this.borderColor(truncateToWidth(indicator, width))); ++ const indicator = `─── ↑ ${this.scrollOffset} more `; ++ const remaining = width - visibleWidth(indicator); ++ if (remaining >= 0) { ++ result.push(this.borderColor(indicator + "─".repeat(remaining))); ++ } ++ else { ++ result.push(this.borderColor(truncateToWidth(indicator, width))); ++ } + } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Render each visible layout line +@@ -413,7 +455,19 @@ export class Editor { + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; +- for (const layoutLine of visibleLines) { ++ for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) { ++ const layoutLine = visibleLines[visibleIndex]; ++ if (!layoutLine) ++ continue; ++ const absoluteIndex = this.scrollOffset + visibleIndex; ++ const prefix = this.prompt ++ ? (absoluteIndex === 0 ++ ? this.prompt.first ++ : layoutLine.isContinuation ++ ? "" ++ : this.prompt.continuation) ++ : ""; ++ const lineContentWidth = inputWidth + (layoutLine.isContinuation ? this.promptWidth : 0); + let displayText = layoutLine.text; + let lineVisibleWidth = visibleWidth(layoutLine.text); + let cursorInPadding = false; +@@ -439,34 +493,41 @@ export class Editor { + displayText = before + marker + cursor; + lineVisibleWidth = lineVisibleWidth + 1; + // If cursor overflows content width into the padding, flag it +- if (lineVisibleWidth > contentWidth && paddingX > 0) { ++ if (lineVisibleWidth > lineContentWidth && paddingX > 0) { + cursorInPadding = true; + } + } + } + // Calculate padding based on actual visible width +- const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); ++ const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth)); + const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; + // Render the line (no side borders, just horizontal lines above and below) +- result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); ++ result.push(`${leftPadding}${prefix}${displayText}${padding}${lineRightPadding}`); + } + // Render bottom border (with scroll indicator if more content below) + const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); + if (linesBelow > 0) { +- const indicator = `─── ↓ ${linesBelow} more `; +- const remaining = width - visibleWidth(indicator); +- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ if (this.frame === "none") { ++ const indicator = `${" ".repeat(this.promptWidth)}↓ ${linesBelow} more`; ++ result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); ++ } ++ else { ++ const indicator = `─── ↓ ${linesBelow} more `; ++ const remaining = width - visibleWidth(indicator); ++ result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); ++ } + } +- else { ++ else if (this.frame === "horizontal") { + result.push(horizontal.repeat(width)); + } + // Add autocomplete list if active + if (this.autocompleteState && this.autocompleteList) { +- const autocompleteResult = this.autocompleteList.render(contentWidth); ++ const autocompleteResult = this.autocompleteList.render(inputWidth); ++ const autocompletePrefix = " ".repeat(this.promptWidth); + for (const line of autocompleteResult) { + const lineWidth = visibleWidth(line); +- const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); +- result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); ++ const linePadding = " ".repeat(Math.max(0, inputWidth - lineWidth)); ++ result.push(`${leftPadding}${autocompletePrefix}${line}${linePadding}${rightPadding}`); + } + } + return result; +@@ -726,7 +787,7 @@ export class Editor { + this.insertCharacter(data); + } + } +- layoutText(contentWidth) { ++ layoutText(contentWidth, continuationWidth) { + const layoutLines = []; + if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + // Empty editor +@@ -734,6 +795,7 @@ export class Editor { + text: "", + hasCursor: true, + cursorPos: 0, ++ isContinuation: false, + }); + return layoutLines; + } +@@ -749,18 +811,20 @@ export class Editor { + text: line, + hasCursor: true, + cursorPos: this.state.cursorCol, ++ isContinuation: false, + }); + } + else { + layoutLines.push({ + text: line, + hasCursor: false, ++ isContinuation: false, + }); + } + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")], continuationWidth); + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) +@@ -796,12 +860,14 @@ export class Editor { + text: chunk.text, + hasCursor: true, + cursorPos: adjustedCursorPos, ++ isContinuation: chunkIndex > 0, + }); + } + else { + layoutLines.push({ + text: chunk.text, + hasCursor: false, ++ isContinuation: chunkIndex > 0, + }); + } + } +@@ -1439,7 +1505,7 @@ export class Editor { + * - startCol: starting column in the logical line + * - length: length of this visual line segment + */ +- buildVisualLineMap(width) { ++ buildVisualLineMap(width, continuationWidth = this.lastContinuationWidth) { + const visualLines = []; + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; +@@ -1453,7 +1519,7 @@ export class Editor { + } + else { + // Line needs wrapping - use word-aware wrapping +- const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); ++ const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")], continuationWidth); + for (const chunk of chunks) { + visualLines.push({ + logicalLine: i, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82de24032..ceec11afc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + importers: .: @@ -11,6 +14,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:packages/session-query/tool-session-query '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) @@ -4291,7 +4297,10 @@ importers: dependencies: '@earendil-works/pi-tui': specifier: 0.80.7 - version: 0.80.7 + version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + saxes: + specifier: 6.0.0 + version: 6.0.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -10830,7 +10839,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.80.7': + '@earendil-works/pi-tui@0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946)': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..d0b328aa8b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.81.1' + +patchedDependencies: + '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0a664ca988..a30475d683 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -97,6 +97,7 @@ function workspaceManifests(): WorkspaceManifest[] { const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], + '@deepseek-ai/dsh-tui': ['lib/prompt.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', From bd4bc84283e6d9bd189e36732c4d483fd715eee4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:31 +0800 Subject: [PATCH 08/16] feat(install): consolidate checkouts under ~/.dsh/source and route PATH through a stable current symlink --- README.i18n.yaml | 6 +- README.md | 12 +- README.zh.md | 12 +- scripts/install.sh | 128 ++++++++++++++---- .../request-response.expected.json | 4 +- 5 files changed, 122 insertions(+), 40 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index c6390e407a..7584d4f293 100644 --- a/README.i18n.yaml +++ b/README.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 -README.md: 8b3a46081503ac9a29cc791cc302066e33e0f995 -README.zh.md: c73e2c70119d5b82d4629041a7a16848f7acbe92 +# pnpm run verify-translation-pairing --write README.md +README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3 +README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f diff --git a/README.md b/README.md index 8b3a460815..f9f7294b42 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,22 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. -The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. +The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. ## Use DeepSeek Harness ### Web UI -For the recommended local interface, build the frontend after installation and after each update, then start the Web UI: +For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink): ```sh -pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web dsh web ``` diff --git a/README.zh.md b/README.zh.md index c73e2c7011..88cbf8522d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,16 +16,22 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 -安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 +安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 ## 使用 DeepSeek Harness ### Web UI -推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI: +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析): ```sh -pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web dsh web ``` diff --git a/scripts/install.sh b/scripts/install.sh index bc60dee983..41d5c749c1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,16 +3,27 @@ # # curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh # -# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node, -# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build — -# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx), -# symlinks `dsh` onto PATH, records your API credentials in the Harness home -# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`. +# It clones the harness under ~/.dsh/source (the master clone at +# ~/.dsh/source/master), adds a per-install staging worktree at +# ~/.dsh/source/staging- on branch dsh-staging/, checks +# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs +# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source +# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink +# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, +# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, +# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps +# successive upgrades in one place instead of scattered sibling clones, and lets +# staging worktrees share the master clone's object store. The PATH symlink +# resolves through `current`, so an upgrade repoints one stable symlink instead +# of relinking PATH: the `dsh` on PATH never moves and can never dangle. # # When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it reuses that checkout and skips the clone/update, leaving -# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE -# to a different directory opts back into the normal clone/update path. +# than `curl ... | sh`) it reuses that checkout in place and skips the +# clone/worktree setup, leaving the working tree untouched and linking `dsh` +# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout +# is not a managed staging worktree under the source container); DSH_REF is +# ignored in that mode. Setting DSH_SOURCE to a different directory opts back +# into the normal clone/worktree path. # # When run through `curl | sh` the script text arrives on stdin, so every # prompt and the final launch read the controlling terminal (/dev/tty) directly; @@ -21,7 +32,9 @@ # Overridable via environment: # DSH_REF branch or tag to clone/checkout (default: master) # DSH_REPO clone URL (default: the GitHub repo) -# DSH_SOURCE checkout location (default: ~/.dsh/source) +# DSH_SOURCE source container directory (default: ~/.dsh/source) +# DSH_MASTER master clone directory (default: $DSH_SOURCE/master) +# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding the personal config (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript @@ -30,19 +43,31 @@ set -eu DSH_REF=${DSH_REF:-master} DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} -# Remember whether the caller pinned a source location before defaulting it, so -# in-repo detection only repoints an unset DSH_SOURCE. +# DSH_SOURCE is the container directory that holds the master clone and every +# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether +# the caller pinned the source container before defaulting it, so in-repo +# detection only repoints an unset DSH_SOURCE. if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} +DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} +# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh +# -> /bin/dsh. Fresh installs and upgrades repoint this one symlink; the +# PATH launcher itself is written once and never moves. In-repo reuse ignores it. +DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} +# One UTC basic timestamp names this install's staging branch and worktree. +DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) +DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP +DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP # --- in-repo detection --------------------------------------------------------- # Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell # name and no file path resolves; running a checked-out copy (`sh # scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose # parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing -# elsewhere opts back into the clone/update path. +# reuse that checkout in place — link `dsh` straight at it and skip the +# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into +# the clone/worktree path. IN_REPO=0 if [ -f "$0" ]; then _self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir='' @@ -52,7 +77,9 @@ if [ -f "$0" ]; then && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then IN_REPO=1 - DSH_SOURCE=$_repo_root + # In-repo reuse links `dsh` at this checkout as-is; the master/staging + # split applies only to fresh clone installs. + DSH_STAGING=$_repo_root fi fi fi @@ -120,7 +147,13 @@ confirm() { } printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}" -printf '%ssource %s @ %s%s\n' "$DIM" "$DSH_SOURCE" "$DSH_REF" "$RST" +if [ "$IN_REPO" = 1 ]; then + printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST" +else + printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST" + printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST" + printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST" +fi # --- 1. dependency check ------------------------------------------------------- step "Checking dependencies" @@ -170,36 +203,73 @@ else fi fi -# --- 2. clone (or update) the source ------------------------------------------ +# --- 2. clone the master and lay out the staging worktree --------------------- +# Fresh installs keep one real clone at $DSH_MASTER and check the running code +# out as a git worktree at $DSH_STAGING, so every checkout lives under +# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the +# existing checkout untouched. if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_SOURCE" + step "Using existing checkout at $DSH_STAGING" info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)" else -step "Fetching source into $DSH_SOURCE" -if [ -d "$DSH_SOURCE/.git" ]; then - info "existing checkout found — updating" - git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF" - # Reset the checkout to the freshly fetched tip. FETCH_HEAD (not +step "Fetching source into $DSH_MASTER" +if [ -d "$DSH_MASTER/.git" ]; then + info "existing master clone found — updating" + git -C "$DSH_MASTER" fetch origin "$DSH_REF" + # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not # origin/) so this resolves for a tag as well as a branch, and -B makes # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD + git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD else - mkdir -p "$(dirname "$DSH_SOURCE")" - git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE" + mkdir -p "$DSH_SOURCE" + git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" fi + +step "Adding staging worktree at $DSH_STAGING" +[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." +# The staging worktree owns the branch dsh runs from; the master clone stays on +# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the +# master clone's info/exclude, which every linked worktree inherits. +git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ + || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD +_exclude="$DSH_MASTER/.git/info/exclude" +if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then + printf '.agents/merge.lock\n' >>"$_exclude" +fi +mkdir -p "$DSH_STAGING/.agents" +: >"$DSH_STAGING/.agents/merge.lock" fi # --- 3. install dependencies (no build; the launcher runs from source) -------- step "Installing dependencies with pnpm (this can take a while)" -( cd "$DSH_SOURCE" && pnpm install ) +( cd "$DSH_STAGING" && pnpm install ) -[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" +[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" # --- 4. put `dsh` on PATH ------------------------------------------------------ +# Clone installs go through a stable `current` symlink so an upgrade repoints +# one symlink (current -> new worktree) and the PATH launcher never moves: +# PATH/dsh -> current/bin/dsh -> /bin/dsh. In-repo reuse links PATH +# straight at the checkout, since that checkout is not a managed worktree. step "Linking dsh into $DSH_BIN_DIR" mkdir -p "$DSH_BIN_DIR" -ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh" -info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh" +if [ "$IN_REPO" = 1 ]; then + DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh + ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" + info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" +else + # Point `current` at this staging worktree with `ln -sfn`: -f replaces an + # existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing + # an existing symlink-to-directory and dropping the new link *inside* the old + # worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir + # symlink the same way. The swap is one unlink+symlink pair on a local fs; the + # installer holds no other process racing this path. + ln -sfn "$DSH_STAGING" "$DSH_CURRENT" + info "pointed $DSH_CURRENT -> $DSH_STAGING" + DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh + ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" + info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" +fi case ":$PATH:" in *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index cb25d0f061..e62327eb24 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", From 2fda7c1da5797aa42d000abc66d596b22e4d8923 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:53:31 +0800 Subject: [PATCH 09/16] feat(cli): default session store to global ~/.dsh/sessions --- apps/cli/cordis.yml | 4 ++++ apps/cli/src/app-cli-entry.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..7b63f4255b 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -84,6 +84,10 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' +# Session store root. AppCLIEntry resolves the engineering default to a +# global dir under the Harness home ($DSH_HOME, else ~/.dsh): sessions live +# in one place across every cwd, not a project-local ./.sessions. The +# persistenceRoot profile key (user config) still overrides this per field. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 29e20b87b8..344c66e2b3 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -117,10 +117,11 @@ export class AppCLIEntry { } /** - * Compose the patch set from the three non-yml config sources: profile - * json (user config), CLI flags, and the resolved frontend dist. Patches - * replace a row's config wholesale, so each patched row's yml static - * values are re-read here (bypass parse) and merged under the overrides. + * Compose the patch set from the non-yml config sources: computed + * engineering defaults (the global session root), profile json (user + * config, overriding those defaults), CLI flags, and the resolved frontend + * dist. Patches replace a row's config wholesale, so each patched row's yml + * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { const rows = this.parseYmlRows() @@ -131,6 +132,12 @@ 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 1) overwrites this same field via last-write-wins in put(). + put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) + // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) From f9d2ab09eaedb39f8d5cbbd5c539b548efeba78f Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 19:30:04 +0800 Subject: [PATCH 10/16] fix(ci): stabilize TUI cwd-abbreviation and skill-catalog test expectations Two CI-only test failures on the personal TUI/skills stack: - packages/ui/tui/tests/tui.spec.ts anchored the dark-palette test at process.cwd(), which is not guaranteed under $HOME; on CI runners the prompt rendered an absolute path instead of the `~/` abbreviation. Anchor cwd under homedir() so the assertion is deterministic. - examples/tui-agent/tests/tui-keyless-smoke.e2e.ts asserted stale dsh-customize / dsh-upgrade skill descriptions. Sync the expectations to the bundled SKILL.md frontmatter. --- examples/tui-agent/tests/tui-keyless-smoke.e2e.ts | 4 ++-- packages/ui/tui/tests/tui.spec.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 6aa1f428a3..1f4ea70d6e 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -383,8 +383,8 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { inspect: async (cwd) => { header = await readLoggedRequestHeader(cwd) }, }) expect(header.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) - expect(header.prefix).toContain('- `dsh-customize`: Customize a dsh installation. Use before any requested change that potentailly impacts the checkout that powers the current DSH process or installed `dsh` command, including code, docs, skills, configuration, tests, commit history, or PR-branch updates; do not edit the personal staging checkout directly.') - expect(header.prefix).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback checkout. Use when the user asks to update or upgrade DSH.') + expect(header.prefix).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") + expect(header.prefix).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') expect(header.prefix).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 827bc0e6d8..9ee6b93674 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4150,9 +4150,11 @@ describe('terminal mounting', () => { } } const terminal = new QueryFailTerminal() + // Anchor cwd under $HOME so the prompt renders the `~/` abbreviation + // deterministically; process.cwd() is not guaranteed under $HOME in CI. const result = await createTuiTestHarness(terminal, vi.fn(), { config: { theme: { color: true } }, - cwd: process.cwd(), + cwd: join(homedir(), 'projects', 'dsh-tui'), }) await tick() expect(terminal.output).toContain('\x1b[94m~/') From 84fe617a018d1100bfd4fb8f632a8de0683c0039 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 19:32:15 +0800 Subject: [PATCH 11/16] fix(tui): breathe running glyph symmetrically instead of blanking at the trough The running status glyph clipped its cosine throb to a blank column below STATUS_FADE_MIN_OPACITY, so each breath read as bold->dim->disappear rather than bold->dim->bold. Remove the cutoff and the constant; the trough now renders as the dimmest gray, giving a symmetric breathe. Turn-boundary appear/disappear is unchanged (fade envelope and non-truecolor visible threshold). --- packages/ui/tui/src/session/timing.ts | 40 ++++++++++----------------- packages/ui/tui/tests/tui.spec.ts | 12 ++++---- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/packages/ui/tui/src/session/timing.ts b/packages/ui/tui/src/session/timing.ts index a85097cb76..d96d598202 100644 --- a/packages/ui/tui/src/session/timing.ts +++ b/packages/ui/tui/src/session/timing.ts @@ -29,19 +29,12 @@ export const STATUS_PULSE_PERIOD_MS = 1400 /** * Brightness floor of the running throb, as a fraction of the settled gray. At - * 0 the pulse swells from fully invisible (a blank glyph column, see - * {@link STATUS_FADE_MIN_OPACITY}) up to full and back, so the dimmest point of - * each breath truly disappears rather than lingering as a faint mark. + * 0 the pulse swells from the near-background trough up to full and back. The + * trough is still rendered as the dimmest gray, not clipped to a blank, so the + * cosine breathes symmetrically bold→dim→bold. */ export const STATUS_PULSE_FLOOR = 0 -/** - * Opacity below which the truecolor running glyph is hidden entirely (a blank - * column) instead of painted as a near-background gray, so the trough of the - * pulse reads as invisible. The fixed glyph width is preserved by the blank. - */ -export const STATUS_FADE_MIN_OPACITY = 0.12 - /** * Muted-gray foreground the truecolor running glyph fades through, from the * near-background trough (opacity 0) to the settled dim gray (opacity 1). Same @@ -243,8 +236,8 @@ export function runningPhaseGlyph(events: readonly SessionEvent[], running: bool /** * The running throb's brightness at continuous clock `nowMs`: a cosine between * {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the - * dim glyph breathes without ever blinking off. Multiplied by the fade envelope - * to gate appear/disappear. + * dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the + * fade envelope, which alone drives appear/disappear at turn boundaries. * * @param nowMs - Monotonic render clock in milliseconds. * @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1]. @@ -256,16 +249,16 @@ export function pulseLevel(nowMs: number): number { } /** - * One frame of the running glyph at fade `opacity` (0 = invisible trough, - * 1 = settled dim gray). The character and its width never change — only the - * gray fades — so the prompt caret column stays fixed and the glyph reads as - * the caret dimly appearing and disappearing, never a colored indicator. + * One frame of the running glyph at fade `opacity` (0 = near-background trough + * gray, 1 = settled dim gray). The character and its width never change — only + * the gray fades — so the prompt caret column stays fixed and the glyph reads as + * the caret dimly breathing, never a colored indicator. * - * With truecolor the glyph's 24-bit gray foreground interpolates between - * {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade and the - * running throb render as brightness; below {@link STATUS_FADE_MIN_OPACITY} it - * is hidden entirely so the pulse trough disappears. Without truecolor there is - * no per-frame gray, so `visible` (driven by the fade envelope, not the opacity) + * With truecolor the glyph's 24-bit gray foreground interpolates continuously + * between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade + * and the running throb render as a smooth, symmetric brightness swing with no + * hard cutoff to clip the trough into a blank. Without truecolor there is no + * per-frame gray, so `visible` (driven by the fade envelope, not the opacity) * shows the glyph in the palette's muted role or leaves a blank column — a * single dim appear/disappear at fixed width, still dim rather than accent, and * no throb-driven blink. With color off entirely a visible glyph is bare, @@ -277,7 +270,7 @@ export function pulseLevel(nowMs: number): number { * @param truecolor - Whether the terminal accepts 24-bit foreground codes. * @param opacity - Brightness fraction in [0, 1] for the truecolor gray. * @param visible - Whether the non-truecolor fallback shows the glyph at all. - * @returns The dim-gray glyph at this opacity, or a single space when hidden. + * @returns The gray glyph at this opacity, or a single space when hidden. */ export function fadeGlyph( glyph: string, @@ -289,9 +282,6 @@ export function fadeGlyph( ): string { if (truecolor && colorEnabled) { const o = Math.min(Math.max(opacity, 0), 1) - // Below the visibility threshold the glyph is fully hidden, so the pulse - // trough disappears rather than lingering as a near-background gray. - if (o < STATUS_FADE_MIN_OPACITY) return ' ' const [tr, tg, tb] = STATUS_FADE_GRAY.trough const [sr, sg, sb] = STATUS_FADE_GRAY.settled const r = Math.round(tr + (sr - tr) * o) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 9ee6b93674..52015387a6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1616,7 +1616,7 @@ describe('pi-tui chat lifecycle and transcript', () => { return r } - it('throbs the running glyph in dim gray, swelling from invisible to full, never accent', async () => { + it('throbs the running glyph in dim gray, breathing between trough and full without blanking, never accent', async () => { let clock = 0 let chunkIndex = 0 const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock }) @@ -1631,11 +1631,13 @@ describe('pi-tui chat lifecycle and transcript', () => { return result.terminal.output } - // The pulse swells from fully invisible at its trough to the settled peak - // and back. At phase 0 (t=1400, pulse level 0, past fade-in) the glyph is - // hidden — the slot carries no ● — so the dimmest breath truly disappears. + // The pulse breathes between the dimmest trough gray and the settled peak + // and back, never blanking. At phase 0 (t=1400, pulse level 0, past fade-in) + // the glyph still paints the trough gray, so the breath dims but never + // disappears — a symmetric bold→dim→bold throb. const trough = await frameAt(1_400) - expect(trough).not.toMatch(/●/u) + expect(trough).toMatch(/●/u) + expect(glyphGray(trough)).toBe(43) // Half a period later (t=2100, pulse peak) it paints the brightest gray. const peak = await frameAt(2_100) expect(glyphGray(peak)).toBe(136) From 46bbbce109c59437cdc47da4b92135c2af8552b3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 20:49:44 +0800 Subject: [PATCH 12/16] refactor(tui): split createTuiChat into chat/ sub-controllers Extract model-command, questions, and resume sub-machines from the ~1600-line createTuiChat closure into src/chat/ factories that take explicit dependency bundles (shared ChatChannelDeps/ChannelNotice). Reorganize src/ so chat/ holds all chat-channel concerns (former input and session/ files move under it); xml-tool-output moves to components/; TuiRuntime/TuiResumeHost move to runtime.ts. index.ts drops 2067->~1530 lines. Behavior identical: 167 tests and all TUI snapshots pass unchanged. --- ...27-tui-chat-channel-module-split.i18n.yaml | 6 + ...026-07-27-tui-chat-channel-module-split.md | 36 + ...-07-27-tui-chat-channel-module-split.zh.md | 36 + .../ui/tui/src/{ => chat}/autocomplete.ts | 4 +- packages/ui/tui/src/chat/channel.ts | 31 + .../tui/src/{ => chat}/file-autocomplete.ts | 2 +- packages/ui/tui/src/chat/helpers.ts | 151 ++++ packages/ui/tui/src/chat/model-command.ts | 191 +++++ packages/ui/tui/src/chat/questions.ts | 168 +++++ packages/ui/tui/src/chat/resume.ts | 245 ++++++ .../ui/tui/src/{ => chat}/skill-invocation.ts | 2 +- .../ui/tui/src/{session => chat}/timing.ts | 2 +- .../ui/tui/src/{session => chat}/tokens.ts | 2 +- packages/ui/tui/src/components/transcript.ts | 4 +- .../src/{ => components}/xml-tool-output.ts | 0 packages/ui/tui/src/config.ts | 2 +- packages/ui/tui/src/index.ts | 698 ++---------------- packages/ui/tui/src/runtime.ts | 45 ++ .../ui/tui/tests/file-autocomplete.spec.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 2 +- packages/ui/tui/tests/xml-tool-output.spec.ts | 2 +- 21 files changed, 1003 insertions(+), 628 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md create mode 100644 .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md rename packages/ui/tui/src/{ => chat}/autocomplete.ts (97%) create mode 100644 packages/ui/tui/src/chat/channel.ts rename packages/ui/tui/src/{ => chat}/file-autocomplete.ts (99%) create mode 100644 packages/ui/tui/src/chat/helpers.ts create mode 100644 packages/ui/tui/src/chat/model-command.ts create mode 100644 packages/ui/tui/src/chat/questions.ts create mode 100644 packages/ui/tui/src/chat/resume.ts rename packages/ui/tui/src/{ => chat}/skill-invocation.ts (98%) rename packages/ui/tui/src/{session => chat}/timing.ts (99%) rename packages/ui/tui/src/{session => chat}/tokens.ts (98%) rename packages/ui/tui/src/{ => components}/xml-tool-output.ts (100%) create mode 100644 packages/ui/tui/src/runtime.ts diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml new file mode 100644 index 0000000000..a22f2b68fa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.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-27-tui-chat-channel-module-split.md +2026-07-27-tui-chat-channel-module-split.md: 56b345b670cd8426780bfdd8c2b2f5719461554c +2026-07-27-tui-chat-channel-module-split.zh.md: d74844a762bc519d0f499696fe343567eebfe920 diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md new file mode 100644 index 0000000000..56b345b670 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui chat channel module split + +Status: implemented + +English | [中文](2026-07-27-tui-chat-channel-module-split.zh.md) + +## Problem + +`packages/ui/tui/src/index.ts` had grown past 2000 lines. Most of it was one `createTuiChat` factory: a ~1600-line closure holding roughly forty mutable variables and as many nested closures. Model selection, the ask-user-question queue, and session resume were tangled into that single scope, so a reader could not follow any one concern without holding the whole file in their head, and unrelated edits collided. A prior pass had grouped `src/` into `components/`, `session/`, `extension/`, but the entry file itself and the loose top-level input files (`autocomplete.ts`, `file-autocomplete.ts`, `skill-invocation.ts`, `xml-tool-output.ts`) were untouched. + +## Decision + +The chat channel's cohesive sub-machines are extracted from `createTuiChat` into `src/chat/`, each a factory that takes an explicit dependency bundle instead of closing over the entry scope: + +- `chat/model-command.ts` — `createModelController`: the queued `/model` command, the model+reasoning-effort selector overlay, and the selected model's context-window resolution. Owns the context-window cache that the prompt and status views read. +- `chat/questions.ts` — `createQuestionQueue`: the user-interaction provider and the one-at-a-time FIFO ask-user-question overlays. +- `chat/resume.ts` — `createResumeController`: the `/resume` selector, per-candidate summary reads, the pre-handoff preflight, the terminal handoff, and the durable resume-hint command. +- `chat/helpers.ts` — zero-state helpers (`formatCwd`, `gitBranch`, surface/tool-call derivations, session-reference cards), the `HintEditor`, and banner-reveal constants. +- `chat/channel.ts` — `ChatChannelDeps` (the collaborator surface every sub-controller shares) and `ChannelNotice` (mixed in by the controllers that report outcomes). Each `*Deps` extends these, so the shared surface has one definition. + +`src/` is reorganized so `chat/` holds every chat-channel concern: the sub-controllers above plus the former input files and the former `session/` files (`timing.ts`, `tokens.ts`) all move under `chat/`. `xml-tool-output.ts` moves under `components/`. The host/process boundary interfaces (`TuiRuntime`, `TuiResumeHost`) move to `src/runtime.ts`. After the split `src/` is `chat/`, `components/`, `extension/`, and the top-level `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`; `index.ts` drops from 2067 to ~1530 lines and now constructs and wires the three controllers. + +The convention for a controller's dependency bundle: stable value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`, and each controller's own services) are destructured once; the channel callbacks (`appendNotice`, `requestRender`, `isDisposed`, `agentStatus`) stay on `deps` so a controller always calls the channel's current implementation. `channel.ts`'s JSDoc states this rule. + +## Alternatives considered + +- **Free functions taking a shared mutable context object.** Rejected: it would re-expose the same forty-field grab-bag the split set out to remove, just under a parameter name. +- **Extracting the status/timing animation controller too.** Deferred: `runningStatus` is read directly by the prompt caret animation in `updatePromptValues`, so a controller boundary there would leak its internal state back through getters — a leaky seam for little gain. It stays inline in `index.ts`. + +## Consequences + +Each concern is now readable and testable in isolation, and the shared dependency surface is defined once instead of copied into three interfaces. The cost: `index.ts` constructs the controllers and threads the callback bundle, and the model controller is a `let` forward-reference (`updatePromptValues` closes over it, but it is built later once `appendNotice`/`overlayManager` exist), carrying one justified `prefer-const` disable and a deferred first paint. + +## Testing + +Behavior is unchanged: all existing package tests and TUI snapshots pass without re-recording, which is the contract for this refactor. diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md new file mode 100644 index 0000000000..d74844a762 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui 聊天通道模块拆分 + +Status: implemented + +[English](2026-07-27-tui-chat-channel-module-split.md) | 中文 + +## Problem + +`packages/ui/tui/src/index.ts` 已超过 2000 行,其中绝大部分是单个 `createTuiChat` 工厂:一个约 1600 行的闭包,持有约四十个可变变量以及同等数量的嵌套闭包。模型选择、ask-user-question 队列、会话恢复都缠绕在这一个作用域里,读者无法在不把整份文件装进脑子的前提下理清任何单一关注点,互不相关的改动也会彼此冲突。此前一轮已把 `src/` 归组为 `components/`、`session/`、`extension/`,但入口文件本身以及散落在顶层的输入相关文件(`autocomplete.ts`、`file-autocomplete.ts`、`skill-invocation.ts`、`xml-tool-output.ts`)未动。 + +## Decision + +聊天通道内聚的子机制从 `createTuiChat` 中抽出,迁入 `src/chat/`,每个都是接收显式依赖包的工厂,而非闭包捕获入口作用域: + +- `chat/model-command.ts` — `createModelController`:排队执行的 `/model` 命令、模型加推理力度(reasoning-effort)的选择浮层,以及所选模型上下文窗口的解析。持有供提示行与状态视图读取的上下文窗口缓存。 +- `chat/questions.ts` — `createQuestionQueue`:user-interaction provider 以及一次仅一个的 FIFO ask-user-question 浮层。 +- `chat/resume.ts` — `createResumeController`:`/resume` 选择器、逐候选摘要读取、交接前预检、终端交接,以及持久化的恢复提示命令。 +- `chat/helpers.ts` — 无状态辅助函数(`formatCwd`、`gitBranch`、surface/工具调用派生、会话引用卡片)、`HintEditor`,以及横幅揭示常量。 +- `chat/channel.ts` — `ChatChannelDeps`(每个子控制器共享的协作者面)与 `ChannelNotice`(由需要上报结果的控制器混入)。各 `*Deps` 继承它们,使共享面只有一处定义。 + +`src/` 随之重组,使 `chat/` 汇集所有聊天通道关注点:上述子控制器,加上原来的输入文件与原 `session/` 文件(`timing.ts`、`tokens.ts`)都迁到 `chat/` 之下。`xml-tool-output.ts` 迁到 `components/` 之下。宿主/进程边界接口(`TuiRuntime`、`TuiResumeHost`)迁到 `src/runtime.ts`。拆分后 `src/` 为 `chat/`、`components/`、`extension/`,以及顶层的 `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`;`index.ts` 从 2067 行降至约 1530 行,现负责构造并接线这三个控制器。 + +控制器依赖包的约定:稳定的取值型协作者(`ctx`、`resolved`、`palette`、`overlayManager`,以及各控制器自有的服务)一次性解构;通道回调(`appendNotice`、`requestRender`、`isDisposed`、`agentStatus`)保留在 `deps` 上,使控制器始终调用通道当前的实现。`channel.ts` 的 JSDoc 陈述了此规则。 + +## Alternatives considered + +- **接收共享可变上下文对象的自由函数。** 否决:那会把拆分本要消除的四十字段大杂烩,仅换个参数名重新暴露出来。 +- **同时抽出状态/计时动画控制器。** 推迟:`runningStatus` 被 `updatePromptValues` 中的提示光标动画直接读取,在此设控制器边界会让其内部状态经 getter 反向泄漏——收益甚微的漏隙缝。它继续内联在 `index.ts` 中。 + +## Consequences + +每个关注点现可独立阅读与测试,共享依赖面只定义一次,而非复制进三个接口。代价:`index.ts` 负责构造这些控制器并穿针引线地传入回调包;模型控制器是 `let` 前向引用(`updatePromptValues` 闭包捕获它,但它要待 `appendNotice`/`overlayManager` 就绪后才构造),因而带一处有正当理由的 `prefer-const` 禁用与一次延后的首帧绘制。 + +## Testing + +行为不变:现有的包测试与 TUI 快照全部无需重录即通过,这正是本次重构的契约。 diff --git a/packages/ui/tui/src/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts similarity index 97% rename from packages/ui/tui/src/autocomplete.ts rename to packages/ui/tui/src/chat/autocomplete.ts index d8a639ad2f..d63ed3614d 100644 --- a/packages/ui/tui/src/autocomplete.ts +++ b/packages/ui/tui/src/chat/autocomplete.ts @@ -1,7 +1,7 @@ /** * Editor autocomplete provider merging path-only file candidates and optional * session-reference snapshots with the base slash-command completions. - * @module @deepseek-ai/dsh-tui/autocomplete + * @module @deepseek-ai/dsh-tui/chat/autocomplete */ import { @@ -15,7 +15,7 @@ import { formatSessionReferenceMention, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' -import { displayInlineText } from './components/text.ts' +import { displayInlineText } from '../components/text.ts' import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts' /** Merge path-only file candidates and optional session snapshots with commands. */ diff --git a/packages/ui/tui/src/chat/channel.ts b/packages/ui/tui/src/chat/channel.ts new file mode 100644 index 0000000000..bb46aad780 --- /dev/null +++ b/packages/ui/tui/src/chat/channel.ts @@ -0,0 +1,31 @@ +/** + * Shared collaborator surface every chat-channel sub-controller receives from + * `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps} + * (and {@link ChannelNotice} when it reports outcomes) with the extra services + * it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`) + * are stable for the channel's life; the callbacks stay on the object so a + * controller always calls the channel's current implementation. + * @module @deepseek-ai/dsh-tui/chat/channel + */ + +import type { Context } from 'cordis' +import type { TuiOverlayManager } from '../extension/overlay-manager.ts' +import type { Palette } from '../components/theme.ts' +import type { ResolvedTuiConfig } from '../config.ts' + +/** Collaborators shared by every chat-channel sub-controller. */ +export interface ChatChannelDeps { + readonly ctx: Context + readonly resolved: ResolvedTuiConfig + readonly palette: Palette + readonly overlayManager: TuiOverlayManager + /** Redraw the channel. */ + requestRender(): void + /** Whether the channel has begun shutting down. */ + isDisposed(): boolean +} + +/** Append a channel notice line; controllers that report outcomes mix this in. */ +export interface ChannelNotice { + appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void +} diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/chat/file-autocomplete.ts similarity index 99% rename from packages/ui/tui/src/file-autocomplete.ts rename to packages/ui/tui/src/chat/file-autocomplete.ts index 23a3a7c1fa..5bd4282d5d 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/ui/tui/src/chat/file-autocomplete.ts @@ -3,7 +3,7 @@ * paths only: selected values remain ordinary prompt text and file contents * stay behind the model-facing `read` tool. * - * @module @deepseek-ai/dsh-tui/file-autocomplete + * @module @deepseek-ai/dsh-tui/chat/file-autocomplete */ import { lstat, readdir } from 'node:fs/promises' diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts new file mode 100644 index 0000000000..a01ad640a7 --- /dev/null +++ b/packages/ui/tui/src/chat/helpers.ts @@ -0,0 +1,151 @@ +/** + * Zero-state helpers for the interactive chat channel: prompt-directory and + * Git-branch formatting, surface/tool-call derivations over the session log, + * session-reference context cards, the placeholder editor, and banner-reveal + * timing constants. None of these close over channel state. + * @module @deepseek-ai/dsh-tui/chat/helpers + */ + +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { + CURSOR_MARKER, + Editor, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** Editor that shows a placeholder without making it editable content. */ +export class HintEditor extends Editor { + /** Placeholder shown in the empty input row; `undefined` hides it. */ + hint: string | undefined + /** Prompt text rendered before the placeholder, matching the live prompt width. */ + hintPrefix = '' + + override render(width: number): string[] { + const lines = super.render(width) + if (this.hint === undefined || this.getText() !== '') return lines + const content = lines[0] + /* v8 ignore next -- Editor always renders one content row. */ + if (content === undefined) return lines + const padding = ' '.repeat(this.getPaddingX()) + /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ + const marker = this.focused ? CURSOR_MARKER : '' + const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) + const placeholder = truncateToWidth(this.hint, available, '') + const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) + lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` + return lines + } +} + +/** + * Format the session working directory as a prompt label: `~` for home, + * `~/rel` for a home-relative path, the raw path otherwise. + * @param cwd - operational working directory from the session header. + * @returns unescaped prompt label. + */ +export function formatCwd(cwd: string | undefined): string { + if (cwd === undefined) return 'cwd unset' + const home = homedir() + const rel = relative(resolve(home), resolve(cwd)) + if (rel === '') return '~' + /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ + if (isAbsolute(rel)) return cwd + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` + return cwd +} + +/** + * Resolve the current Git branch for the prompt context line. + * @param cwd - operational working directory to query. + * @returns branch name, or `undefined` outside a worktree or on any failure. + */ +export function gitBranch(cwd: string): string | undefined { + try { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), + ) + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1_000, + }).trim() + /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ + return branch === '' ? undefined : branch + } catch (_gitUnavailableOrOutsideWorktree) { + return undefined + } +} + +/** + * Sequence numbers currently visible on the session surface. + * @param session - session whose surface nodes to read. + * @returns the set of visible event sequence numbers. + */ +export function activeSurfaceSeqs(session: Session): Set { + return new Set(session.surface.nodes) +} + +/** + * Tool-call ids whose owning assistant message is on the active surface. + * @param session - session whose events to scan. + * @param active - sequence numbers currently on the surface. + * @returns the set of active tool-call ids. + */ +export function activeToolCallIds(session: Session, active: ReadonlySet): Set { + const ids = new Set() + for (const event of session.events) { + if (event.type !== 'assistant/message' || !active.has(event.seq)) continue + for (const block of event.data.content) { + if (block.type === 'tool-call') ids.add(block.id) + } + } + return ids +} + +/** + * Read a session-reference context card's display labels from event meta. + * @param meta - envelope-context meta to inspect. + * @returns per-reference labels, or `undefined` when meta is not a reference card. + */ +export function sessionReferenceCard(meta: unknown): string[] | undefined { + if (typeof meta !== 'object' || meta === null) return undefined + const record = meta as Record + if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined + const references = record['references'] as unknown[] + const labels: string[] = [] + for (const reference of references) { + if (typeof reference !== 'object' || reference === null) return undefined + const entry = reference as Record + const sessionId = entry['sessionId'] + const label = entry['label'] + if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined + labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) + } + return labels +} + +/** + * Session-reference cards attached to a prompt or steering message envelope. + * @param event - the user or steering message event to read. + * @returns per-envelope-context reference-label lists, empty when none. + */ +export function promptReferenceCards( + event: Extract, +): string[][] { + return event.data.envelope?.prefixContexts.flatMap((context) => { + const card = sessionReferenceCard(context.meta) + return card === undefined ? [] : [card] + }) ?? [] +} + +/** Milliseconds between banner sweep-reveal frames (~60 fps). */ +export const BANNER_REVEAL_INTERVAL_MS = 15 + +/** Number of sweep frames the banner reveal spreads the terminal width over. */ +export const BANNER_REVEAL_STEPS = 24 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts new file mode 100644 index 0000000000..133a3d0d9f --- /dev/null +++ b/packages/ui/tui/src/chat/model-command.ts @@ -0,0 +1,191 @@ +/** + * Model-selection sub-controller for the interactive chat channel: the queued + * `/model` command, the keyboard model selector overlay with reasoning-effort + * selection, and resolution of the selected model's context window. Owns the + * context-window cache the prompt and status views read; the caller owns the + * shared {@link AgentLlmTargetRef}. + * @module @deepseek-ai/dsh-tui/chat/model-command + */ + +import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' +import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { TuiOverlaySession } from '../extension/types.ts' +import { displayText } from '../components/text.ts' +import { + ModelDialog, + readModelChoices, + targetLabel, + targetReasoningLabel, + type ModelChoice, + type ModelDialogSelection, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the model controller needs from the chat channel. */ +export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice { + /** Shared selected-target handle owned by the channel. */ + readonly target: AgentLlmTargetRef +} + +/** Model-selection controller for one chat channel. */ +export interface ModelController { + /** Resolved context window of the selected model, or `undefined` if unknown. */ + contextWindow(): number | undefined + /** Queue a `/model` command; empty argument opens the selector. */ + queueModelCommand(raw: string): void + /** Drop the pending context-window resolution (shutdown). */ + resetContextResolution(): void + /** Forget the tracked selector overlay (shutdown). */ + clearOverlay(): void +} + +type ContextResolution = + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + +/** + * Build the model-selection controller for one chat channel. + * @param deps - channel collaborators and shared target handle. + * @returns the controller wired to the channel's overlay and prompt views. + */ +export function createModelController(deps: ModelControllerDeps): ModelController { + const { ctx, resolved, palette, overlayManager, target } = deps + let contextWindow: number | undefined + let contextResolution: Promise | undefined + let modelOverlay: TuiOverlaySession | undefined + let modelCommands = Promise.resolve() + + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution: Promise = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( + info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), + (error: unknown) => ({ kind: 'error', error } as const), + ) + contextResolution = resolution + void resolution.then((result) => { + if (contextResolution !== resolution) return + if (result.kind === 'error') { + deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + deps.requestRender() + }) + } + resolveContextWindow(target.current) + + const selectModel = ( + selected: ModelChoice, + explicitReasoning?: { effort: ReasoningEffortId | undefined }, + ): void => { + const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model + const reasoningEffort = explicitReasoning === undefined + ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) + : explicitReasoning.effort + if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) + return + } + target.current = { + provider: selected.provider, + model: selected.model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } + resolveContextWindow(target.current) + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice([ + `Model selected: ${targetLabel(selected)}.`, + ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], + 'New steps will use it.', + ].join(' ')) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + void modelOverlay?.close() + const session = overlayManager.open({ + create: () => new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selection: ModelDialogSelection) => { + void session.close() + selectModel(selection.choice, { effort: selection.reasoningEffort }) + }, + () => { void session.close() }, + ), + options: { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + modelOverlay = session + void session.closed.then(() => { + if (modelOverlay === session) modelOverlay = undefined + }) + deps.requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (deps.isDisposed()) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + deps.appendNotice('Usage: /model [provider/]model', 'warning') + return + } + + let matches: ModelChoice[] + if (parts.length === 2) { + matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) + } else { + const value = argument + const qualified = choices.filter(choice => targetLabel(choice) === value) + matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) + } + if (matches.length === 0) { + deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') + return + } + const selected = matches[0] + /* v8 ignore next -- a non-empty matches array always has index zero. */ + if (selected === undefined) return + selectModel(selected) + } + + return { + contextWindow: () => contextWindow, + queueModelCommand(raw: string): void { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + }, + resetContextResolution(): void { + contextResolution = undefined + }, + clearOverlay(): void { + modelOverlay = undefined + }, + } +} diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts new file mode 100644 index 0000000000..e5f2c8b806 --- /dev/null +++ b/packages/ui/tui/src/chat/questions.ts @@ -0,0 +1,168 @@ +/** + * Ask-user-question sub-machine for the interactive chat channel. Registers the + * user-interaction provider, presents one question overlay at a time in FIFO + * order, and settles each request on answer, abort, overlay error, or channel + * shutdown. + * @module @deepseek-ai/dsh-tui/chat/questions + */ + +import { errorChain } from '@deepseek-ai/dsh-llm' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' +import type { TuiOverlaySession } from '../extension/types.ts' +import { QuestionDialog } from '../components/dialogs.ts' +import type { ChatChannelDeps } from './channel.ts' + +/** One queued or active ask-user-question request and its running answers. */ +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: TuiOverlaySession | undefined +} + +/** Collaborators the question queue needs from the chat channel. */ +export type QuestionQueueDeps = ChatChannelDeps + +/** Ask-user-question controller for one chat channel. */ +export interface QuestionQueue { + /** Reject the active and all queued questions (shutdown). */ + rejectAll(): void + /** Remove the user-interaction provider registration. */ + unregister(): void +} + +/** + * Build the ask-user-question queue for one chat channel. + * @param deps - channel collaborators and overlay host. + * @returns the controller used at shutdown to drain and unregister. + */ +export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { + const { ctx, resolved, palette, overlayManager } = deps + const questionQueue: PendingQuestion[] = [] + let activeQuestion: PendingQuestion | undefined + + const removeAbortListener = (pending: PendingQuestion): void => { + pending.request.signal?.removeEventListener('abort', pending.onAbort) + } + + const rejectQuestion = (pending: PendingQuestion): void => { + void pending.overlay?.close() + pending.overlay = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + 'ask_user_question was interrupted before the user answered', + 'ASK_ABORTED', + )) + } + + const startNextQuestion = (): void => { + if (activeQuestion !== undefined || deps.isDisposed()) return + const pending = questionQueue.shift() + if (pending === undefined) return + activeQuestion = pending + const show = (): void => { + const question = pending.request.questions[pending.index] + if (question === undefined) { + activeQuestion = undefined + removeAbortListener(pending) + pending.resolve({ answers: pending.answers }) + startNextQuestion() + return + } + const session = overlayManager.open({ + ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, + create: () => new QuestionDialog( + question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, + resolved.maxQuestionOptions, + palette, + (selection) => { + pending.overlay = undefined + void session.close() + pending.answers.push({ id: question.id, ...selection }) + pending.index += 1 + show() + }, + () => { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + }, + ), + options: { + width: resolved.questionDialogWidth, + maxHeight: resolved.questionDialogMaxHeight, + anchor: 'bottom-left', + margin: { bottom: 1 }, + }, + }) + pending.overlay = session + void session.closed.then((result) => { + if (pending.overlay !== session) return + pending.overlay = undefined + /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ + if (result.reason !== 'error') return + activeQuestion = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + `ask_user_question TUI failed: ${errorChain(result.error)}`, + 'ASK_ABORTED', + )) + startNextQuestion() + }) + deps.requestRender() + } + show() + } + + const unregister = ctx.userInteraction.registerProvider({ + ask(request) { + return new Promise((resolveAnswer, reject) => { + const pending: PendingQuestion = { + request, + index: 0, + answers: [], + resolve: resolveAnswer, + reject, + overlay: undefined, + onAbort: () => { + if (activeQuestion === pending) { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + return + } + // A non-active pending ask remains in the queue until this listener settles it. + questionQueue.splice(questionQueue.indexOf(pending), 1) + rejectQuestion(pending) + }, + } + request.signal?.addEventListener('abort', pending.onAbort, { once: true }) + questionQueue.push(pending) + startNextQuestion() + }) + }, + }) + + return { + rejectAll(): void { + if (activeQuestion !== undefined) { + const pending = activeQuestion + activeQuestion = undefined + rejectQuestion(pending) + } + for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + }, + unregister, + } +} diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts new file mode 100644 index 0000000000..2f0fdd423c --- /dev/null +++ b/packages/ui/tui/src/chat/resume.ts @@ -0,0 +1,245 @@ +/** + * Session-resume sub-controller for the interactive chat channel: the + * `/resume` selector, per-candidate summary reads that tolerate a corrupt + * neighbor, the pre-handoff preflight, the terminal handoff itself, and the + * durable resume-hint command printed on exit. + * @module @deepseek-ai/dsh-tui/chat/resume + */ + +import type { TUI } from '@earendil-works/pi-tui' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { errorChain } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { + SessionLogSnapshot, + SessionQueryService, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { HintEditor } from './helpers.ts' +import { formatCwd } from './helpers.ts' +import type { TuiOverlaySession } from '../extension/types.ts' +import type { TuiRuntime } from '../runtime.ts' +import type { Config } from '../config.ts' +import { + ResumePicker, + summarizeResumeCandidate, + type ResumeCandidate, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the resume controller needs from the chat channel. */ +export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { + readonly agent: Agent + readonly config: Config + readonly runtime: TuiRuntime + readonly persistence: SessionPersistence | undefined + readonly sessionQuery: SessionQueryService | undefined + readonly ui: TUI + readonly editor: HintEditor + /** Current agent status, re-read at each resume precondition point. */ + agentStatus(): AgentStatus +} + +/** Session-resume controller for one chat channel. */ +export interface ResumeController { + /** Open the current-workspace searchable session selector. */ + showResume(): void + /** + * The resume command for the current session — the configured template with + * every `{session}` filled — but only once the session is durably persisted; + * `undefined` otherwise. + */ + currentResumeCommand(): Promise +} + +/** + * Build the session-resume controller for one chat channel. + * @param deps - channel collaborators, terminal handles, and optional services. + * @returns the controller wired to the `/resume` command and exit hint. + */ +export function createResumeController(deps: ResumeControllerDeps): ResumeController { + const { + ctx, agent, config, runtime, resolved, palette, overlayManager, + persistence, sessionQuery, ui, editor, + } = deps + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 + + /** + * Persisted sessions for this workspace, newest first. Empty when no + * persistence backend is mounted or a listing failure would otherwise block + * exit or crash `/resume`; the resume hint is best-effort convenience. + */ + const listWorkspaceSessions = async (): Promise => { + if (persistence === undefined) return [] + let all: readonly SessionHeader[] + try { + all = await persistence.list() + } catch { + // A listing failure must never block terminal exit or crash `/resume`. + return [] + } + return all + .filter(header => header.cwd === agent.session.header.cwd) + } + + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet, + ): Promise => { + try { + let snapshot: SessionLogSnapshot + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + snapshot, + agent.session.id, + agent.session.header.cwd, + providers, + ) + } catch (error: unknown) { + return { + record, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } + } + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = deps.agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = deps.agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { + if (resumeInFlight) return + resumeInFlight = true + let terminalReleased = false + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') + return + } + /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ + if (deps.isDisposed()) return + await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending. + if (deps.isDisposed()) return + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending. + if (deps.isDisposed()) return + ui.stop() + terminalReleased = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + if (!deps.isDisposed()) { + if (terminalReleased) { + ui.start() + ui.setFocus(editor) + deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } + } finally { + resumeInFlight = false + } + } + + return { + currentResumeCommand: async (): Promise => { + if (config.resumeCommand === undefined) return undefined + const sessions = await listWorkspaceSessions() + if (!sessions.some(header => header.id === agent.session.id)) return undefined + return config.resumeCommand.replaceAll('{session}', agent.session.id) + }, + showResume(): void { + if (agent.status !== 'idle') { + deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + deps.appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (deps.isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (deps.isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: host => new ResumePicker( + candidates, + resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined + }) + deps.requestRender() + }, (error: unknown) => { + if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') + }) + }, + } +} diff --git a/packages/ui/tui/src/skill-invocation.ts b/packages/ui/tui/src/chat/skill-invocation.ts similarity index 98% rename from packages/ui/tui/src/skill-invocation.ts rename to packages/ui/tui/src/chat/skill-invocation.ts index 5857e93ea3..7eb7a555ae 100644 --- a/packages/ui/tui/src/skill-invocation.ts +++ b/packages/ui/tui/src/chat/skill-invocation.ts @@ -1,7 +1,7 @@ /** * Manual `/skill: [instructions]` parsing and model-visible rendering for * the terminal front door. - * @module @deepseek-ai/dsh-tui/skill-invocation + * @module @deepseek-ai/dsh-tui/chat/skill-invocation */ import { assertNever } from '@deepseek-ai/dsh-llm' diff --git a/packages/ui/tui/src/session/timing.ts b/packages/ui/tui/src/chat/timing.ts similarity index 99% rename from packages/ui/tui/src/session/timing.ts rename to packages/ui/tui/src/chat/timing.ts index d96d598202..13477adfa0 100644 --- a/packages/ui/tui/src/session/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -3,7 +3,7 @@ * front door. Timing buckets are replayed from the session event stream; the * running glyph fades in on turn start, throbs while the turn runs, and fades * out on turn end. - * @module @deepseek-ai/dsh-tui/session/timing + * @module @deepseek-ai/dsh-tui/chat/timing */ import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/ui/tui/src/session/tokens.ts b/packages/ui/tui/src/chat/tokens.ts similarity index 98% rename from packages/ui/tui/src/session/tokens.ts rename to packages/ui/tui/src/chat/tokens.ts index 1711c96ede..ab54292a1d 100644 --- a/packages/ui/tui/src/session/tokens.ts +++ b/packages/ui/tui/src/chat/tokens.ts @@ -1,7 +1,7 @@ /** * Running token accounting for the terminal footer. Usage is keyed per * turn/step so replayed or re-emitted usage replaces rather than double-counts. - * @module @deepseek-ai/dsh-tui/session/tokens + * @module @deepseek-ai/dsh-tui/chat/tokens */ import type { TokenUsage } from '@deepseek-ai/dsh-llm' diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 41835ee31f..b01ffc5e48 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -25,7 +25,7 @@ import type { ToolResultView, } from '@deepseek-ai/dsh-tools' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { renderUnknownXml } from '../xml-tool-output.ts' +import { renderUnknownXml } from './xml-tool-output.ts' import { displayInlineText, displayText } from './text.ts' import { gradientText, type Palette } from './theme.ts' import { contentText, type ParsedArguments } from './content.ts' @@ -34,7 +34,7 @@ import { formatTimingTotals, stepTimingAt, type StepPosition, -} from '../session/timing.ts' +} from '../chat/timing.ts' /** Concatenate the text of every block of one type, separated by blank lines. */ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { diff --git a/packages/ui/tui/src/xml-tool-output.ts b/packages/ui/tui/src/components/xml-tool-output.ts similarity index 100% rename from packages/ui/tui/src/xml-tool-output.ts rename to packages/ui/tui/src/components/xml-tool-output.ts diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index a0114be972..a48a1d704c 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -10,7 +10,7 @@ import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' +} from './chat/file-autocomplete.ts' /** Theme and prompt-template settings for the pi-tui terminal mode. */ export interface TuiThemeConfig { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1dffff23e9..2a682c6689 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,25 +5,18 @@ * @module @deepseek-ai/dsh-tui */ -import { execFileSync } from 'node:child_process' -import { homedir } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, - CURSOR_MARKER, - Editor, Key, Spacer, Text, TUI, ProcessTerminal, matchesKey, - truncateToWidth, visibleWidth, type EditorTheme, type SlashCommand, - type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' @@ -31,7 +24,6 @@ import { assembleContextFor, installAgentLlmTarget, type Agent, - type AgentLlmTarget, type AgentLlmTargetRef, type AgentStatus, type HookContext, @@ -40,36 +32,27 @@ import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type { CommandResult } from '@deepseek-ai/dsh-commands' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { renderUnknownXml } from './xml-tool-output.ts' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { renderUnknownXml } from './components/xml-tool-output.ts' import type {} from '@deepseek-ai/dsh-llm-retry' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { displayPromptContent, SessionId, - type Session, type SessionEvent, - type SessionHeader, } from '@deepseek-ai/dsh-session' import { foldGoal } from '@deepseek-ai/dsh-goal' import { parseSessionReferenceText, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' -import type { - SessionLogSnapshot, - SessionRecord, -} from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' import type { SkillService } from '@deepseek-ai/dsh-skill' -import { - UserInteractionError, - type AskUserQuestionAnswer, - type AskUserQuestionAnswerItem, - type AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-interaction' +// Type import declaration-merges the `userInteraction` service onto `Context`; +// the ask-user-question queue is registered by ./chat/questions. +import type {} from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, @@ -92,7 +75,7 @@ import { formatTokens, recordEventUsage, sessionTokens, -} from './session/tokens.ts' +} from './chat/tokens.ts' import { fadeGlyph, formatQueuedStatus, @@ -104,7 +87,7 @@ import { STATUS_FADE_MS, TIMING_BUCKET_GLYPHS, type StepPosition, -} from './session/timing.ts' +} from './chat/timing.ts' import { resolveTuiConfig, type Config, @@ -123,30 +106,40 @@ import { formatDiagnosticNumber, formatDiagnosticTime, initialTarget, - ModelDialog, - QuestionDialog, - readModelChoices, - ResumePicker, StatusCardComponent, PromptContextComponent, - summarizeResumeCandidate, targetLabel, - targetReasoningLabel, - type ModelChoice, - type ModelDialogSelection, - type ResumeCandidate, type StatusCardRow, } from './components/dialogs.ts' import { parseSkillCommand, renderSkillInvocation, SKILL_COMMAND_PREFIX, -} from './skill-invocation.ts' -import { ReferenceAutocompleteProvider } from './autocomplete.ts' -import { WorkspaceFileSearch } from './file-autocomplete.ts' +} from './chat/skill-invocation.ts' +import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts' +import { + activeSurfaceSeqs, + activeToolCallIds, + BANNER_REVEAL_INTERVAL_MS, + BANNER_REVEAL_STEPS, + formatCwd, + gitBranch, + HintEditor, + promptReferenceCards, + sessionReferenceCard, +} from './chat/helpers.ts' +import { + createModelController, + type ModelController, +} from './chat/model-command.ts' +import { createQuestionQueue } from './chat/questions.ts' +import { createResumeController } from './chat/resume.ts' +import type { TuiResumeHost, TuiRuntime } from './runtime.ts' +import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' -export { renderSkillInvocation } from './skill-invocation.ts' +export { renderSkillInvocation } from './chat/skill-invocation.ts' +export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, TuiConfigSchema, @@ -160,7 +153,7 @@ export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' +} from './chat/file-autocomplete.ts' export type { TuiComponent, @@ -187,17 +180,6 @@ declare module 'cordis' { } } -/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ -export interface TuiResumeHost { - /** - * Dispose the current app and replace it with a runtime for `sessionId`. - * Success does not return. A host may reject before it commits teardown; - * after commit it owns fatal reporting and process exit. - * @param sessionId - validated persisted session selected by the user. - */ - handoff(sessionId: SessionId): Promise -} - /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -228,52 +210,6 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** Runtime boundary used by the interactive TUI. */ -export interface TuiRuntime { - /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ - terminal: Terminal - /** Exit hook used by terminal shutdown or a target-agent startup failure. */ - exit(code: number): void - /** - * Override the prompt's logical working-directory label without changing the session directory used by tools. - * @param cwd - Operational working directory from the session header. - * @returns Unescaped label; the TUI makes terminal controls visible. - */ - formatCwd?: (cwd: string | undefined) => string - /** - * Override the Git branch shown in the prompt context line; production resolves it once at mount. - * @param cwd - Operational working directory from the session header. - * @returns Unescaped branch name, or `undefined` outside a Git worktree. - */ - gitBranch?: (cwd: string) => string | undefined - /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ - now?(): number - /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ - handoffResume?: TuiResumeHost['handoff'] -} - -/** Editor that shows a placeholder without making it editable content. */ -class HintEditor extends Editor { - hint: string | undefined - hintPrefix = '' - - override render(width: number): string[] { - const lines = super.render(width) - if (this.hint === undefined || this.getText() !== '') return lines - const content = lines[0] - /* v8 ignore next -- Editor always renders one content row. */ - if (content === undefined) return lines - const padding = ' '.repeat(this.getPaddingX()) - /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ - const marker = this.focused ? CURSOR_MARKER : '' - const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) - const placeholder = truncateToWidth(this.hint, available, '') - const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) - lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` - return lines - } -} - interface RunningStatus { turn: number | undefined timer: ReturnType @@ -291,97 +227,12 @@ interface FadingStatus { timer: ReturnType } -interface PendingQuestion { - request: AskUserQuestionRequest - index: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void - overlay: TuiOverlaySession | undefined -} - /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ dispose(): Promise } -function formatCwd(cwd: string | undefined): string { - if (cwd === undefined) return 'cwd unset' - const home = homedir() - const rel = relative(resolve(home), resolve(cwd)) - if (rel === '') return '~' - /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */ - if (isAbsolute(rel)) return cwd - if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` - return cwd -} - -function gitBranch(cwd: string): string | undefined { - try { - const env = Object.fromEntries( - Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), - ) - const branch = execFileSync('git', ['branch', '--show-current'], { - cwd, - encoding: 'utf8', - env, - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 1_000, - }).trim() - /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ - return branch === '' ? undefined : branch - } catch (_gitUnavailableOrOutsideWorktree) { - return undefined - } -} - -function activeSurfaceSeqs(session: Session): Set { - return new Set(session.surface.nodes) -} - -function sessionReferenceCard(meta: unknown): string[] | undefined { - if (typeof meta !== 'object' || meta === null) return undefined - const record = meta as Record - if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined - const references = record['references'] as unknown[] - const labels: string[] = [] - for (const reference of references) { - if (typeof reference !== 'object' || reference === null) return undefined - const entry = reference as Record - const sessionId = entry['sessionId'] - const label = entry['label'] - if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined - labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) - } - return labels -} - -function promptReferenceCards(event: Extract): string[][] { - return event.data.envelope?.prefixContexts.flatMap((context) => { - const card = sessionReferenceCard(context.meta) - return card === undefined ? [] : [card] - }) ?? [] -} - -function activeToolCallIds(session: Session, active: ReadonlySet): Set { - const ids = new Set() - for (const event of session.events) { - if (event.type !== 'assistant/message' || !active.has(event.seq)) continue - for (const block of event.data.content) { - if (block.type === 'tool-call') ids.add(block.id) - } - } - return ids -} - -/** Milliseconds between banner sweep-reveal frames (~60 fps). */ -const BANNER_REVEAL_INTERVAL_MS = 15 - -/** Number of sweep frames the banner reveal spreads the terminal width over. */ -const BANNER_REVEAL_STEPS = 24 - /** * Start the interactive pi-tui channel for an already-created target agent. * @param ctx - agent, tools, session-event, and user-interaction context. @@ -453,22 +304,16 @@ export function createTuiChat( const toolCards = new Map() const allToolCards = new Set() const liveErrors = new Set() - const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() const referenceControllers = new Set() - let activeQuestion: PendingQuestion | undefined - let modelOverlay: TuiOverlaySession | undefined - let resumeOverlay: TuiOverlaySession | undefined - let resumeInFlight = false - let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } - let contextWindow: number | undefined - let contextResolution: Promise< - | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } - | { readonly kind: 'error'; readonly error: unknown } - > | undefined - let modelCommands = Promise.resolve() + // `updatePromptValues` (defined below) closes over the model controller, but + // the controller needs `appendNotice`/`overlayManager`, defined after that + // closure. Declare here, assign once after those exist, and defer the first + // `updatePromptValues()` call until after the assignment so no read precedes it. + // eslint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. + let modelController!: ModelController const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed @@ -507,6 +352,7 @@ export function createTuiChat( const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) + const contextWindow = modelController.contextWindow() contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted( `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, )}`) @@ -543,7 +389,6 @@ export function createTuiChat( ) indicatorValue.set(`${caret}${palette.muted(' ')}`) } - updatePromptValues() const promptContext = new PromptContextComponent( parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), @@ -622,130 +467,17 @@ export function createTuiChat( const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target) - const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { - contextWindow = undefined - const resolution = selected === undefined - ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) - : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( - info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const), - (error: unknown) => ({ kind: 'error', error } as const), - ) - contextResolution = resolution - void resolution.then((result) => { - if (contextResolution !== resolution) return - if (result.kind === 'error') { - appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') - return - } - contextWindow = result.contextWindow - requestRender() - }) - } - resolveContextWindow(target.current) - - const selectModel = ( - selected: ModelChoice, - explicitReasoning?: { effort: ReasoningEffortId | undefined }, - ): void => { - const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model - const reasoningEffort = explicitReasoning === undefined - ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) - : explicitReasoning.effort - if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) - return - } - target.current = { - provider: selected.provider, - model: selected.model, - ...reasoningEffort === undefined ? {} : { reasoningEffort }, - } - resolveContextWindow(target.current) - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice([ - `Model selected: ${targetLabel(selected)}.`, - ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], - 'New steps will use it.', - ].join(' ')) - } - - const showModelSelector = (choices: readonly ModelChoice[]): void => { - const current = target.current === undefined ? 'unset' : targetLabel(target.current) - if (choices.length === 0) { - appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') - return - } - void modelOverlay?.close() - const session = overlayManager.open({ - create: () => new ModelDialog( - choices, - target.current, - resolved.maxModelOptions, - palette, - (selection: ModelDialogSelection) => { - void session.close() - selectModel(selection.choice, { effort: selection.reasoningEffort }) - }, - () => { void session.close() }, - ), - options: { - width: resolved.modelDialogWidth, - maxHeight: resolved.modelDialogMaxHeight, - anchor: 'center', - margin: 1, - }, - }) - modelOverlay = session - void session.closed.then(() => { - if (modelOverlay === session) modelOverlay = undefined - }) - requestRender() - } - - const handleModelCommand = async (raw: string): Promise => { - const choices = await readModelChoices(ctx, target.current) - if (disposed) return - const argument = raw.trim() - if (argument === '') { - showModelSelector(choices) - return - } - const parts = argument.split(/\s+/u) - if (parts.length > 2) { - appendNotice('Usage: /model [provider/]model', 'warning') - return - } - - let matches: ModelChoice[] - if (parts.length === 2) { - matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) - } else { - const value = argument - const qualified = choices.filter(choice => targetLabel(choice) === value) - matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) - } - if (matches.length === 0) { - appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') - return - } - if (matches.length > 1) { - appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') - return - } - const selected = matches[0] - /* v8 ignore next -- a non-empty matches array always has index zero. */ - if (selected === undefined) return - selectModel(selected) - } - - const queueModelCommand = (raw: string): void => { - modelCommands = modelCommands.then(async () => { - await handleModelCommand(raw) - }).catch((error: unknown) => { - if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') - }) - } + modelController = createModelController({ + ctx, + resolved, + palette, + overlayManager, + target, + appendNotice, + requestRender, + isDisposed, + }) + updatePromptValues() const renderStatus = (): void => { streaming?.invalidate() @@ -1062,147 +794,38 @@ export function createTuiChat( requestRender() } - const removeAbortListener = (pending: PendingQuestion): void => { - pending.request.signal?.removeEventListener('abort', pending.onAbort) - } - - const rejectQuestion = (pending: PendingQuestion): void => { - void pending.overlay?.close() - pending.overlay = undefined - removeAbortListener(pending) - pending.reject(new UserInteractionError( - 'ask_user_question was interrupted before the user answered', - 'ASK_ABORTED', - )) - } - - const startNextQuestion = (): void => { - if (activeQuestion !== undefined || disposed) return - const pending = questionQueue.shift() - if (pending === undefined) return - activeQuestion = pending - const show = (): void => { - const question = pending.request.questions[pending.index] - if (question === undefined) { - activeQuestion = undefined - removeAbortListener(pending) - pending.resolve({ answers: pending.answers }) - startNextQuestion() - return - } - const session = overlayManager.open({ - ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, - create: () => new QuestionDialog( - question, - pending.index + 1, - pending.request.questions.length, - pending.request.questions.length - pending.answers.length, - resolved.maxQuestionOptions, - palette, - (selection) => { - pending.overlay = undefined - void session.close() - pending.answers.push({ id: question.id, ...selection }) - pending.index += 1 - show() - }, - () => { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - }, - ), - options: { - width: resolved.questionDialogWidth, - maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, - }, - }) - pending.overlay = session - void session.closed.then((result) => { - if (pending.overlay !== session) return - pending.overlay = undefined - /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ - if (result.reason !== 'error') return - activeQuestion = undefined - removeAbortListener(pending) - pending.reject(new UserInteractionError( - `ask_user_question TUI failed: ${errorChain(result.error)}`, - 'ASK_ABORTED', - )) - startNextQuestion() - }) - requestRender() - } - show() - } - - const disposeUserInteraction = ctx.userInteraction.registerProvider({ - ask(request) { - return new Promise((resolveAnswer, reject) => { - const pending: PendingQuestion = { - request, - index: 0, - answers: [], - resolve: resolveAnswer, - reject, - overlay: undefined, - onAbort: () => { - if (activeQuestion === pending) { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - return - } - // A non-active pending ask remains in the queue until this listener settles it. - questionQueue.splice(questionQueue.indexOf(pending), 1) - rejectQuestion(pending) - }, - } - request.signal?.addEventListener('abort', pending.onAbort, { once: true }) - questionQueue.push(pending) - startNextQuestion() - }) - }, + const questions = createQuestionQueue({ + ctx, + resolved, + palette, + overlayManager, + requestRender, + isDisposed, }) - /** - * Persisted sessions for this workspace, newest first. Empty when no - * persistence backend is mounted or a listing failure would otherwise block - * exit or crash `/resume`; the resume hint is best-effort convenience. - */ - const listWorkspaceSessions = async (): Promise => { - if (persistence === undefined) return [] - let all: readonly SessionHeader[] - try { - all = await persistence.list() - } catch { - // A listing failure must never block terminal exit or crash `/resume`. - return [] - } - return all - .filter(header => header.cwd === agent.session.header.cwd) - } - - /** - * The resume command for the current session — the configured template with - * every `{session}` filled — but only once the session is durably persisted, - * so a session abandoned before its first flush yields no hint (resuming that - * id would fail to load). - */ - const currentResumeCommand = async (): Promise => { - if (config.resumeCommand === undefined) return undefined - const sessions = await listWorkspaceSessions() - if (!sessions.some(header => header.id === agent.session.id)) return undefined - return config.resumeCommand.replaceAll('{session}', agent.session.id) - } + const resume = createResumeController({ + ctx, + agent, + config, + runtime, + resolved, + palette, + overlayManager, + persistence, + sessionQuery, + ui, + editor, + appendNotice, + requestRender, + isDisposed, + agentStatus, + }) const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() - contextResolution = undefined + modelController.resetContextResolution() clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() @@ -1210,19 +833,14 @@ export function createTuiChat( referenceControllers.clear() await tuiServiceFiber?.dispose() tuiServiceFiber = undefined - if (activeQuestion !== undefined) { - const pending = activeQuestion - activeQuestion = undefined - rejectQuestion(pending) - } - for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + questions.rejectAll() await overlayManager.dispose() - modelOverlay = undefined - disposeUserInteraction() + modelController.clearOverlay() + questions.unregister() await runtime.terminal.drainInput(100, 20) ui.stop() if (exitProcess) { - const command = await currentResumeCommand() + const command = await resume.currentResumeCommand() if (command !== undefined) { runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`) } @@ -1316,6 +934,7 @@ export function createTuiChat( const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + const contextWindow = modelController.contextWindow() if (contextWindow !== undefined) { const contextPercent = Math.round(usedContext / contextWindow * 100) context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` @@ -1437,7 +1056,7 @@ export function createTuiChat( description: 'Show or switch this session\'s model', input: { hint: '[[provider/]model]' }, handler: ({ rawInput }) => { - queueModelCommand(rawInput) + modelController.queueModelCommand(rawInput) return { kind: 'success' } }, }) @@ -1469,7 +1088,7 @@ export function createTuiChat( commandCtx.commands.register({ name: 'resume', description: 'List this workspace\'s resumable sessions', - handler: () => { showResume(); return { kind: 'success' } }, + handler: () => { resume.showResume(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'status', @@ -1608,159 +1227,6 @@ export function createTuiChat( }) } - /** Build one display candidate without letting a corrupt neighbor abort the selector. */ - const readResumeCandidate = async ( - record: SessionRecord, - providers: ReadonlySet, - ): Promise => { - try { - let snapshot: SessionLogSnapshot - const live = ctx.sessions.get(record.header.id) - if (live !== undefined) { - snapshot = { - session: structuredClone(live.header), - events: live.events.map(event => structuredClone(event)), - } - } else { - /* v8 ignore next -- caller checks the optional service before mapping records */ - if (sessionQuery === undefined) throw new Error('session query is unavailable') - snapshot = await sessionQuery.readSession(record.header.id) - } - return summarizeResumeCandidate( - record, - snapshot, - agent.session.id, - agent.session.header.cwd, - providers, - ) - } catch (error: unknown) { - return { - record, - title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', - disabledReason: `session cannot be loaded: ${errorChain(error)}`, - } - } - } - - /** Re-read every mutable precondition immediately before terminal handoff. */ - const preflightResume = async (sessionId: SessionId): Promise => { - /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ - if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') - const initialStatus = agentStatus() - if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) - const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) - if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) - const candidate = await readResumeCandidate( - record, - new Set(ctx.llm.listProviders().map(provider => provider.id)), - ) - if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) - const finalStatus = agentStatus() - if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return candidate - } - - const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { - if (resumeInFlight) return - resumeInFlight = true - let terminalReleased = false - try { - const checked = await preflightResume(candidate.record.header.id) - const hostHandoff = runtime.handoffResume - if (hostHandoff === undefined) { - const template = config.resumeCommand - const fallback = template?.replaceAll('{session}', checked.record.header.id) - await overlay.close() - resumeOverlay = undefined - appendNotice(fallback === undefined - ? 'Session is resumable, but this host cannot hand it off in place.' - : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') - return - } - /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ - if (disposed) return - await ctx.sessions.flush(agent.session) - // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) - await overlay.close() - resumeOverlay = undefined - await runtime.terminal.drainInput(100, 20) - // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - ui.stop() - terminalReleased = true - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - if (!disposed) { - if (terminalReleased) { - ui.start() - ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') - } else { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } - } - } finally { - resumeInFlight = false - } - } - - /** Open the current-workspace searchable session selector. */ - const showResume = (): void => { - if (agent.status !== 'idle') { - appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') - return - } - if (sessionQuery === undefined) { - appendNotice('Resume is not available: session query is not mounted.', 'warning') - return - } - const scan = ++resumeScan - void resumeOverlay?.close() - void sessionQuery.listSessions().then(async (records) => { - if (isDisposed() || scan !== resumeScan) return - const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) - const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) - candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt - || a.record.header.id.localeCompare(b.record.header.id)) - if (isDisposed() || scan !== resumeScan) return - const session = overlayManager.open({ - create: host => new ResumePicker( - candidates, - resolved.maxResumeOptions, - runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), - () => host.viewport.rows, - palette, - (candidate) => { void handoffResume(candidate, session) }, - () => { void session.close() }, - ), - options: { - width: '100%', - maxHeight: '100%', - anchor: 'top-left', - margin: 0, - }, - }) - resumeOverlay = session - void session.closed.then(() => { - /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ - if (resumeOverlay === session) resumeOverlay = undefined - }) - requestRender() - }, (error: unknown) => { - if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') - }) - } - editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return @@ -1986,7 +1452,7 @@ export function createTuiChat( }, ) clearStatus() - disposeUserInteraction() + questions.unregister() ui.stop() throw error } diff --git a/packages/ui/tui/src/runtime.ts b/packages/ui/tui/src/runtime.ts new file mode 100644 index 0000000000..4345f656bf --- /dev/null +++ b/packages/ui/tui/src/runtime.ts @@ -0,0 +1,45 @@ +/** + * Host and process boundary the interactive TUI runs against: the resume-handoff + * host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process + * exit, clock, and optional prompt/git overrides). These are plain interfaces so + * tests can drive the channel with a fake terminal. + * @module @deepseek-ai/dsh-tui/runtime + */ + +import type { Terminal } from '@earendil-works/pi-tui' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise +} + +/** Runtime boundary used by the interactive TUI. */ +export interface TuiRuntime { + /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ + terminal: Terminal + /** Exit hook used by terminal shutdown or a target-agent startup failure. */ + exit(code: number): void + /** + * Override the prompt's logical working-directory label without changing the session directory used by tools. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped label; the TUI makes terminal controls visible. + */ + formatCwd?: (cwd: string | undefined) => string + /** + * Override the Git branch shown in the prompt context line; production resolves it once at mount. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped branch name, or `undefined` outside a Git worktree. + */ + gitBranch?: (cwd: string) => string | undefined + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number + /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ + handoffResume?: TuiResumeHost['handoff'] +} diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts index 53dd1f4f1a..18c7deba12 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -6,7 +6,7 @@ import { activeAtToken, formatFileMention, WorkspaceFileSearch, -} from '../src/file-autocomplete.ts' +} from '../src/chat/file-autocomplete.ts' const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 52015387a6..7aa8debf54 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -30,7 +30,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' -import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' +import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' import { appendAssistant, appendUser, diff --git a/packages/ui/tui/tests/xml-tool-output.spec.ts b/packages/ui/tui/tests/xml-tool-output.spec.ts index df270eed20..62fb475d73 100644 --- a/packages/ui/tui/tests/xml-tool-output.spec.ts +++ b/packages/ui/tui/tests/xml-tool-output.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { renderUnknownXml } from '../src/xml-tool-output.ts' +import { renderUnknownXml } from '../src/components/xml-tool-output.ts' const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml( source, From 4efc90d798e13aa16bf5b0fc03f1d8d6fd35a559 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 21:03:36 +0800 Subject: [PATCH 13/16] cleanup(tui): give xml-tool-output.ts a module header like its peers Every other tui source file opens with a multi-line description ending in @module; xml-tool-output.ts had only a one-line header. Align it. --- packages/ui/tui/src/components/xml-tool-output.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/tui/src/components/xml-tool-output.ts b/packages/ui/tui/src/components/xml-tool-output.ts index 3e88120f7d..24beb58c31 100644 --- a/packages/ui/tui/src/components/xml-tool-output.ts +++ b/packages/ui/tui/src/components/xml-tool-output.ts @@ -1,4 +1,8 @@ -/** Conservative readable-tree rendering for model-facing text containing one XML document. */ +/** + * Conservative readable-tree rendering for model-facing text containing one XML + * document, used by the transcript's tool and context cards. + * @module @deepseek-ai/dsh-tui/components/xml-tool-output + */ import { SaxesParser } from 'saxes' From 1ef285d038109562ca1548352a7f98256f8e66ae Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 21:20:06 +0800 Subject: [PATCH 14/16] Restore Cordis mount and unmount names Keep temporary lifecycle descriptions and UI labels explicit. --- ...0-canonical-tool-output-contract.i18n.yaml | 4 +- ...26-07-20-canonical-tool-output-contract.md | 2 +- ...07-20-canonical-tool-output-contract.zh.md | 2 +- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 20 ++-- ...7-08-self-referential-cordis-toolset.zh.md | 20 ++-- ...-20-code-mode-typed-tool-returns.i18n.yaml | 4 +- ...2026-07-20-code-mode-typed-tool-returns.md | 2 +- ...6-07-20-code-mode-typed-tool-returns.zh.md | 2 +- ...04-prune-dead-core-spine-surface.i18n.yaml | 4 +- ...026-07-04-prune-dead-core-spine-surface.md | 2 +- ...-07-04-prune-dead-core-spine-surface.zh.md | 2 +- apps/web/tests/cordis-tool-round.e2e.ts | 28 ++--- .../snapshots/cordis-tool-round/session.jsonl | 104 ++++++++++-------- .../cordis-tool-round/ui.expected.md | 19 ++-- docs/tool-catalog.md | 52 ++++----- examples/README.i18n.yaml | 4 +- examples/README.md | 2 +- examples/README.zh.md | 2 +- .../advanced-toolchain/session.jsonl | 24 ++-- .../system-prompt.expected.md | 26 ++--- .../tool-schemas.expected.json | 38 +++---- .../tests/snapshots/bash-spill/session.jsonl | 2 +- .../escalation-approved/session.jsonl | 4 +- .../escalation-rejected/session.jsonl | 4 +- .../fs-escalation-approved/session.jsonl | 4 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../session-query-spill/session.jsonl | 2 +- examples/cordis-agent/README.i18n.yaml | 4 +- examples/cordis-agent/README.md | 18 +-- examples/cordis-agent/README.zh.md | 18 +-- examples/cordis-agent/composition.md | 2 +- examples/cordis-agent/cordis.yml | 14 +-- .../cordis-agent/tests/cordis-tools.e2e.ts | 20 ++-- examples/cordis-agent/tests/harness.ts | 4 +- .../headless-agent/tests/code-mode.e2e.ts | 10 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 26 ++--- .../stream-json.expected.jsonl | 24 ++-- .../cordis-dynamic-toolchain/session.jsonl | 20 ++-- .../terminal.expected.txt | 14 +-- examples/tui-agent/tests/tui.snapshot.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/tool-call-model.ts | 8 +- .../tests/chat-code-subcalls.spec.tsx | 16 +-- .../tests/chat-tool-row.spec.tsx | 16 +-- .../tests/chat-toolview-slot.spec.tsx | 16 +-- packages/cordis/README.i18n.yaml | 4 +- packages/cordis/README.md | 2 +- packages/cordis/README.zh.md | 4 +- packages/cordis/tool-cordis/README.i18n.yaml | 4 +- packages/cordis/tool-cordis/README.md | 22 ++-- packages/cordis/tool-cordis/README.zh.md | 22 ++-- packages/cordis/tool-cordis/src/guard.ts | 2 +- packages/cordis/tool-cordis/src/index.ts | 38 +++---- packages/cordis/tool-cordis/src/inspect.ts | 4 +- packages/cordis/tool-cordis/src/mount.ts | 4 +- packages/cordis/tool-cordis/src/present.ts | 12 +- packages/cordis/tool-cordis/src/sandbox.ts | 6 +- .../tool-cordis/tests/cross-mount.spec.ts | 44 ++++---- .../cordis/tool-cordis/tests/inspect.spec.ts | 8 +- .../tool-cordis/tests/integration.spec.ts | 18 +-- .../cordis/tool-cordis/tests/mount.spec.ts | 98 ++++++++--------- .../cordis/tool-cordis/tests/present.spec.ts | 18 +-- .../tool-cordis/tests/sandbox-context.spec.ts | 30 ++--- .../tool-cordis/tests/tool-cordis.spec.ts | 4 +- .../tool-cordis/tests/unmount-hmr.spec.ts | 30 ++--- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- .../cordis-tools-pending.expected.txt | 8 +- packages/ui/tui/tests/tui.snapshot.ts | 4 +- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 2 +- scripts/smoke-python-runtime.py | 20 ++-- .../advanced/result.json | 72 ++++++------ .../advanced/session.1.jsonl | 2 +- .../advanced/session.2.jsonl | 2 +- .../advanced/session.jsonl | 26 ++--- 80 files changed, 578 insertions(+), 567 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index f4d8021b53..17d864cb57 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.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/architecture/2026-07-20-canonical-tool-output-contract.md -2026-07-20-canonical-tool-output-contract.md: 822fd0fae02be62d83aa0402cdf6d7d4322b89fb -2026-07-20-canonical-tool-output-contract.zh.md: 3d32114107732c48cb0cb236faa8ec60ad887698 +2026-07-20-canonical-tool-output-contract.md: b2de9480d2659153dfb8a76ee07438d12e5b07c3 +2026-07-20-canonical-tool-output-contract.zh.md: 1ae2654fb1e1d6913bc91c4aeb380dc533a6b258 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 822fd0fae0..b2de9480d2 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -56,7 +56,7 @@ The first-party tools preserve their existing Native text while returning domain | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_try` / `cordis_stop` | Inspection text or typed temporary-Plugin handles | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed temporary-Plugin handles | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index 3d32114107..1ae2654fb1 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -56,7 +56,7 @@ type ToolExecutionResult = | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_try` / `cordis_stop` | 检查文本或类型化的临时 Plugin 句柄 | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的临时 Plugin 句柄 | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 5ac1989116..421933e57a 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: c531b8b7da988b9381f7a1c78f4017977dd321d8 -2026-07-08-self-referential-cordis-toolset.zh.md: ae3ef1170d29cf896d4a5c54a4b23289b981b88f +2026-07-08-self-referential-cordis-toolset.md: 40934fe0e2975c4e068df6ef8f31ed7921df3230 +2026-07-08-self-referential-cordis-toolset.zh.md: 13662b9359aa85895ce85391ebd5a5902cc451cc diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index c531b8b7da..40934fe0e2 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme ## Decision -The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, try an in-memory temporary Plugin, and stop that Plugin to quiescence. +The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. @@ -20,11 +20,11 @@ The vm isolates accidental global pollution, and the context façade hides frame | Tool | Contract | |---|---| -| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_try`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. | -| `cordis_try` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). | -| `cordis_stop` | Stops one `cordis_try` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. | +| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_mount`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. | +| `cordis_mount` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). | +| `cordis_unmount` | Unmounts one `cordis_mount` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. | -`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_try` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_stop`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. +`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_mount` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_unmount`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. ### Sandbox semantics @@ -38,7 +38,7 @@ The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec` ### The internal group and temporary-Plugin lifecycle -Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_try` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_stop` awaits the Plugin fiber's disposal. +Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal. Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. @@ -54,15 +54,15 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( ### Configuration, rendering, and observability -The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_try`, and `cordis_stop`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, try is `execute` carrying code as `rawInput`, and stop is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` plus one shared Cordis accent; the try row retains the shared JavaScript expansion and syntax highlighting. +The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_mount`, and `cordis_unmount`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, mount is `execute` carrying code as `rawInput`, and unmount is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` plus one shared Cordis accent; the mount row retains the shared JavaScript expansion and syntax highlighting. -Model-visible ⟺ logged holds with no new session event type: try and stop are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. +Model-visible ⟺ logged holds with no new session event type: mount and unmount are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. ## Alternatives considered -**A structured per-capability registration tool instead of `cordis_try`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once. +**A structured per-capability registration tool instead of `cordis_mount`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once. -| Dimension | Structured per-capability tools | Single `cordis_try` | +| Dimension | Structured per-capability tools | Single `cordis_mount` | |---|---|---| | Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors | | The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration | diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index ae3ef1170d..13662b9359 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、尝试一个仅存于内存的临时 Plugin,再让该 Plugin 完全停稳。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 @@ -20,11 +20,11 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 | 工具 | 契约 | |---|---| -| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_try` 创建的临时 Plugin。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 | -| `cordis_try` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的 Plugin 挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 | -| `cordis_stop` | 按 id 停止一个 `cordis_try` 临时 Plugin,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、配置或已安装的 Plugin。 | +| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_mount` 创建的临时 Plugin。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 | +| `cordis_mount` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的 Plugin 挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 | +| `cordis_unmount` | 按 id 卸载一个 `cordis_mount` 临时 Plugin,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、配置或已安装的 Plugin。 | -`cordis_inspect` 的段落是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活 Plugin fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_try` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时 Plugin 可跨后续 turn 保持活跃,并在 `cordis_stop`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。[生成的工具目录](../../../../docs/tool-catalog.md)完整呈现面向模型的调用契约。 +`cordis_inspect` 的段落是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活 Plugin fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_mount` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时 Plugin 可跨后续 turn 保持活跃,并在 `cordis_unmount`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。[生成的工具目录](../../../../docs/tool-catalog.md)完整呈现面向模型的调用契约。 ### 沙箱语义 @@ -38,7 +38,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 内部分组与临时 Plugin 生命周期 -每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_try` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_stop` 等待 Plugin fiber 的释放完成。 +每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_unmount` 等待 Plugin fiber 的释放完成。 临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 @@ -54,15 +54,15 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_try` 和 `cordis_stop`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,try 为携带代码 `rawInput` 的 `execute`,stop 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin` 以及统一的 Cordis 强调色;try 行仍使用共用的 JavaScript 展开视图和语法高亮。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,mount 为携带代码 `rawInput` 的 `execute`,unmount 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin` 以及统一的 Cordis 强调色;mount 行仍使用共用的 JavaScript 展开视图和语法高亮。 -「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:try 与 stop 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 +「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:mount 与 unmount 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 ## 曾考虑的替代方案 -**用结构化的逐能力注册工具替代 `cordis_try`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 +**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。 -| 维度 | 结构化逐能力工具 | 单一 `cordis_try` | +| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` | |---|---|---| | Schema 正确性 | `parameters` 仍然是模型编写的 JSON,需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 | | 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 | diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 0dc17721bf..39689747e1 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.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-20-code-mode-typed-tool-returns.md -2026-07-20-code-mode-typed-tool-returns.md: 4d60954b372253f51a2be61a12df153789239d14 -2026-07-20-code-mode-typed-tool-returns.zh.md: 562937b88ccce30d7101f8807144ec03f7ad67b4 +2026-07-20-code-mode-typed-tool-returns.md: 2081d8161f0ee14493a09762b18ec7d9d07ea3c4 +2026-07-20-code-mode-typed-tool-returns.zh.md: 0fa5eec7a96ebba6796dda6221b83e91f14ebf7d diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 4d60954b37..2081d8161f 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -69,7 +69,7 @@ Compute time, wall time, worker heap, cancellation, and fresh-worker isolation r Background producers return a typed canonical handle such as `{ kind: 'background', taskId }` while retaining their established Native sentence. A pre-aborted background call remains a failure because successful output promises an id and no task was created. After `ctx.tasks.start()` publishes the id, task-owned cancellation governs the work: settlement or later cancellation of the enclosing `run_code` call does not kill it. A later program can pass the returned id to `task_output`, and `task_kill`, owner disposal, or service teardown owns cancellation. Foreground execution remains coupled to the call signal. The task lifetime contract is owned by the [background task runtime note](../architecture/2026-06-20-generic-long-running-tool-runtime.md). -Temporary Cordis Plugins follow the same rule: `cordis_try` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `temporary.id`, inspect active or pending state, and pass that id to `cordis_stop` without parsing the stable Native sentence. +Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence. ### Persistence, metadata, and spill diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index 562937b88c..0fa5eec7a9 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -69,7 +69,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper 后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 -临时 Cordis Plugin 遵循同一规则:`cordis_try` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `temporary.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_stop`,无需解析稳定的 Native 语句。 +临时 Cordis Plugin 遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 ### 持久化、元数据与输出落盘 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 93ab3ea3ae..b14e969a84 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.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/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md -2026-07-04-prune-dead-core-spine-surface.md: 10433886d6e8763ca498cb42e0f6b8f35f8c0beb -2026-07-04-prune-dead-core-spine-surface.zh.md: 4502aaebf8b023ca2e43581d25c28eb43e0e73f9 +2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3 +2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index 10433886d6..a6c6086174 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -8,7 +8,7 @@ English | [中文](2026-07-04-prune-dead-core-spine-surface.zh.md) Several package-root exports, result fields, and convenience methods have no production consumer. They survive because tests import internals through public entry points or because a type anticipated a caller that never arrived. Each item is small in isolation, but together they enlarge the SDK contract, generated catalogs, documentation, and regression matrix without enabling a shipped path. -The production corpus is `packages/*/*/src`, example sources/config, and runtime scripts. Tests, package READMEs, and Agent Note prose are evidence of publication but not fixed callers. `cordis_inspect` makes `packages/cordis/tool-cordis/src/api-catalog.ts` model-visible, and `cordis_try` can invoke injected services through guarded real-service proxies, so catalogued service methods and returned shapes are a genuine dynamic product surface. The table therefore distinguishes absence of a fixed repository caller from unreachability: rows touching catalogued vocabulary intentionally contract what model-written mounts can discover and call, while package-root implementation helpers are not reached through that service façade. Exact-symbol searches produce the following inventory: +The production corpus is `packages/*/*/src`, example sources/config, and runtime scripts. Tests, package READMEs, and Agent Note prose are evidence of publication but not fixed callers. `cordis_inspect` makes `packages/cordis/tool-cordis/src/api-catalog.ts` model-visible, and `cordis_mount` can invoke injected services through guarded real-service proxies, so catalogued service methods and returned shapes are a genuine dynamic product surface. The table therefore distinguishes absence of a fixed repository caller from unreachability: rows touching catalogued vocabulary intentionally contract what model-written mounts can discover and call, while package-root implementation helpers are not reached through that service façade. Exact-symbol searches produce the following inventory: | Surface | Production evidence | Simplification | | --- | --- | --- | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md index 4502aaebf8..83603d8a84 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md @@ -8,7 +8,7 @@ Status: proposed 若干包(package)根导出、结果字段和便利方法没有生产消费方。它们之所以存活,要么是因为测试通过公开入口导入了内部实现,要么是因为某个类型预期了一个从未出现的调用者。每一项单独看都很小,但合在一起,它们扩大了 SDK 契约、生成的 catalog、文档和回归矩阵,却没有支撑任何已交付的路径。 -生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_try` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: +生产语料库是 `packages/*/*/src`、示例源码/配置和运行时脚本。测试、包 README 和 Agent Note(agent 决策记录)行文是发布的证据,但不是固定调用者。`cordis_inspect` 使 `packages/cordis/tool-cordis/src/api-catalog.ts` 对模型可见,`cordis_mount` 可以通过受保护的真实服务代理调用注入的服务,因此 catalog 中的服务方法和返回形状是真正的动态产品接口。下表因此区分「没有固定的仓库调用者」与「不可达」:涉及 catalog 词汇的行有意收缩模型编写的 mount 能发现和调用的内容,而包根实现辅助函数并不通过该服务门面可达。精确符号搜索得出以下清单: | 接口 | 生产证据 | 简化方式 | | --- | --- | --- | diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index fb15f77ab5..07f267df56 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario for the opt-in Cordis tools. Record mode drives a real -// model through inspect, try, and stop; replay pins the same shipped Web +// model through inspect, mount, and unmount; replay pins the same shipped Web // composition, durable calls, generic rows, highlighted Plugin source, and // conversation accessibility tree. import { readFile } from 'node:fs/promises' @@ -17,11 +17,11 @@ import { connectFreshWorkspace, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() -const CORDIS_TOOLS = ['cordis_inspect', 'cordis_try', 'cordis_stop'] as const -const TRY_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' +const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const +const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". ' - + `Then call cordis_try with this exact code: ${JSON.stringify(TRY_CODE)}. ` - + 'Read its returned id and call cordis_stop with that exact id. ' + + `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. ` + + 'Read its returned id and call cordis_unmount with that exact id. ' + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.' function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { @@ -105,16 +105,16 @@ describe('web e2e: Cordis tools use the generic row variants', () => { const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first() await inspectRow.waitFor({ timeout: 10_000 }) - const tryRow = page.locator('[data-tool="cordis_try"]').filter({ hasText: 'Try temporary Plugin' }).first() - await tryRow.waitFor({ timeout: 10_000 }) - await tryRow.locator('button[aria-expanded]').click() - await expect.poll(() => tryRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) - .toContain(TRY_CODE) + const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first() + await mountRow.waitFor({ timeout: 10_000 }) + await mountRow.locator('button[aria-expanded]').click() + await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) + .toContain(MOUNT_CODE) - const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop temporary Plugin' }).first() - await stopRow.waitFor({ timeout: 10_000 }) - await expect.poll(() => stopRow.textContent()).toContain('dyn-') - await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok') + const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first() + await unmountRow.waitFor({ timeout: 10_000 }) + await expect.poll(() => unmountRow.textContent()).toContain('dyn-') + await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok') }) it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { diff --git a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl index eeb2ad3768..ab6c86089d 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl +++ b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl @@ -1,48 +1,56 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785146149001,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1785146149060,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785146149060,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_try with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_stop with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785146149062,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785146149128,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785146149129,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785146150219,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785146150219,"data":{"turn":1,"step":1,"index":0,"dt":[142,8,1,0,0,1,0,19,1,30,0,0,1,0,0,21,1,0,0,0,0,47,0,0,0,0,1,26,0,0,7,0,0,0,0,1,28,1,0,0,0,0,27,0,0,1,0,0,25,0,0,53,3,0,0,0,1,0,1,0,1,0,21,1,0,0,0,28,26,0,0,0,0,0,27,0,0,0,0,0,27,1,0,0,0,28,0,0,29,1,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Call"," cord","is","_in","spect"," with"," what"," \"","t","emporary","\"\n","2","."," Call"," cord","is","_t","ry"," with"," the"," exact"," code"," \"","return"," {"," name",":"," \\\"","sn","apshot","-no","op","\\\","," apply","(ctx",")"," {}"," }","\"\n","3","."," Read"," the"," returned"," id"," and"," call"," cord","is","_st","op"," with"," that"," exact"," id","\n","4","."," Reply"," exactly"," C","ORD","IS","_","UI","_D","ONE"," and"," stop","\n\n","Let"," me"," start"," with"," steps"," ","1"," and"," ","2"," since"," they","'re"," independent","."]}} -{"type":"assistant/chunk","seq":99,"time":1785146150940,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":100,"time0":1785146150940,"data":{"turn":1,"step":1,"index":1,"dt":[19,1,0,26,2,0,1,24,1,28],"id":"call_00_6xalzzYPi0G7a0hsGnuo3216","name":"cordis_inspect","args":["","{","\"","what","\"",": ","\"","t","emporary","\"","}"]}} -{"type":"assistant/chunk","seq":111,"time":1785146151097,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":112,"time0":1785146151098,"data":{"turn":1,"step":1,"index":2,"dt":[26,1,0,0,0,24,2,0,0,0,25,1,0,0,0,2,25,0,1,0,0,25,0],"id":"call_01_SY6OIGsbAyt3IG3BIqa12688","name":"cordis_try","args":["","{","\"","code","\"",": ","\"","return"," {"," name",":"," \\\"","sn","apshot","-no","op","\\\","," apply","(ctx",")"," {}"," }","\"","}"]}} -{"type":"assistant/chunk","seq":136,"time":1785146151291,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Call cordis_inspect with what \"temporary\"\n2. Call cordis_try with the exact code \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"\n3. Read the returned id and call cordis_stop with that exact id\n4. Reply exactly CORDIS_UI_DONE and stop\n\nLet me start with steps 1 and 2 since they're independent."}}}} -{"type":"assistant/chunk","seq":137,"time":1785146151292,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6xalzzYPi0G7a0hsGnuo3216","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}} -{"type":"assistant/chunk","seq":138,"time":1785146151292,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_SY6OIGsbAyt3IG3BIqa12688","name":"cordis_try","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}} -{"type":"assistant/chunk","seq":139,"time":1785146151292,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":14106,"outputTokens":186,"cacheReadTokens":2304,"reasoningTokens":93}}}} -{"type":"assistant/chunk","seq":140,"time":1785146151292,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":141,"time":1785146151296,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call cordis_inspect with what \"temporary\"\n2. Call cordis_try with the exact code \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"\n3. Read the returned id and call cordis_stop with that exact id\n4. Reply exactly CORDIS_UI_DONE and stop\n\nLet me start with steps 1 and 2 since they're independent."},{"type":"tool-call","id":"call_00_6xalzzYPi0G7a0hsGnuo3216","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"},{"type":"tool-call","id":"call_01_SY6OIGsbAyt3IG3BIqa12688","name":"cordis_try","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":14106,"outputTokens":186,"cacheReadTokens":2304,"reasoningTokens":93}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140],"surfaceOp":"append"} -{"type":"tool/call","seq":142,"time":1785146151296,"data":{"turn":1,"step":1,"callId":"call_00_6xalzzYPi0G7a0hsGnuo3216","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}} -{"type":"tool/result","seq":143,"time":1785146151299,"data":{"turn":1,"step":1,"callId":"call_00_6xalzzYPi0G7a0hsGnuo3216","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[142],"surfaceOp":"append"} -{"type":"tool/call","seq":144,"time":1785146151300,"data":{"turn":1,"step":1,"callId":"call_01_SY6OIGsbAyt3IG3BIqa12688","name":"cordis_try","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}} -{"type":"tool/result","seq":145,"time":1785146151302,"data":{"turn":1,"step":1,"callId":"call_01_SY6OIGsbAyt3IG3BIqa12688","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} -{"type":"step/end","seq":146,"time":1785146151304,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":147,"time":1785146151305,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":148,"time":1785146152097,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":149,"time0":1785146152097,"data":{"turn":1,"step":2,"index":0,"dt":[119,33,2,0,0,0,24,2,0,0,1,24,2,0,31,21,1,0,1,0,0,26,1,0,1,0,0,23,2,0,0],"texts":["The"," cord","is","_t","ry"," returned"," \"","d","yn","-","1","\""," as"," the"," temporary"," plugin"," id","."," Now"," I"," need"," to"," call"," cord","is","_st","op"," with"," that"," exact"," id","."]}} -{"type":"assistant/chunk","seq":181,"time":1785146152498,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":182,"time0":1785146152498,"data":{"turn":1,"step":2,"index":1,"dt":[19,2,0,0,28,2,0,0,22,2,0,25],"id":"call_00_NJsTqzhejSfRE4hdAwCe4952","name":"cordis_stop","args":["","{","\"","id","\"",": ","\"","d","yn","-","1","\"","}"]}} -{"type":"assistant/chunk","seq":195,"time":1785146152633,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The cordis_try returned \"dyn-1\" as the temporary plugin id. Now I need to call cordis_stop with that exact id."}}}} -{"type":"assistant/chunk","seq":196,"time":1785146152633,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NJsTqzhejSfRE4hdAwCe4952","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":197,"time":1785146152633,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":155,"outputTokens":81,"cacheReadTokens":16512,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":198,"time":1785146152634,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":199,"time":1785146152635,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The cordis_try returned \"dyn-1\" as the temporary plugin id. Now I need to call cordis_stop with that exact id."},{"type":"tool-call","id":"call_00_NJsTqzhejSfRE4hdAwCe4952","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":155,"outputTokens":81,"cacheReadTokens":16512,"reasoningTokens":32}},"sourceEventSeqs":[148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198],"surfaceOp":"append"} -{"type":"tool/call","seq":200,"time":1785146152635,"data":{"turn":1,"step":2,"callId":"call_00_NJsTqzhejSfRE4hdAwCe4952","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":201,"time":1785146152636,"data":{"turn":1,"step":2,"callId":"call_00_NJsTqzhejSfRE4hdAwCe4952","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[200],"surfaceOp":"append"} -{"type":"step/end","seq":202,"time":1785146152638,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":203,"time":1785146152638,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":204,"time":1785146153286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":205,"time0":1785146153286,"data":{"turn":1,"step":3,"index":0,"dt":[90,55,12,1,13,1,0,33,0,0,22,1,1,0,0,1,27,1],"texts":["All"," three"," calls"," succeeded","."," I"," should"," now"," reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\"."]}} -{"type":"assistant/chunk","seq":224,"time":1785146153544,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":225,"time0":1785146153544,"data":{"turn":1,"step":3,"index":1,"dt":[25,0,0,0,0,3],"texts":["C","ORD","IS","_","UI","_D","ONE"]}} -{"type":"assistant/chunk","seq":232,"time":1785146153601,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\"."}}}} -{"type":"assistant/chunk","seq":233,"time":1785146153601,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}} -{"type":"assistant/chunk","seq":234,"time":1785146153601,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":129,"outputTokens":27,"cacheReadTokens":16640,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":235,"time":1785146153601,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":236,"time":1785146153602,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\"."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":129,"outputTokens":27,"cacheReadTokens":16640,"reasoningTokens":19}},"sourceEventSeqs":[204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"} -{"type":"step/end","seq":237,"time":1785146153602,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":238,"time":1785146153603,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785157564667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785157564667,"data":{"turn":1,"step":1,"index":0,"dt":[115,31,3,0,1,0,1,21,1,0,0,0,1,23,0,1,0,0,0,24,2,1,0,0,0,28,2,0,0,0,1,23,2,22,2,1,25,2,0,0,25,27,1,1,0,0,28,2,0,0,0,0,32,1,31,1,0,0,0,9,29,3,0,0,0,1,23,1,0,0,0,27,4,0,0,0,23,2,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Call"," `","cord","is","_in","spect","`"," with"," `","what",":"," \"","t","emporary","\"`\n","2","."," Call"," `","cord","is","_m","ount","`"," with"," the"," exact"," code"," provided","\n","3","."," Read"," the"," returned"," id"," and"," call"," `","cord","is","_un","mount","`"," with"," that"," exact"," id","\n","4","."," Reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","\n\n","Let"," me"," start"," with"," step"," ","1","."]}} +{"type":"assistant/chunk","seq":87,"time":1785157565360,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":88,"time0":1785157565360,"data":{"turn":1,"step":1,"index":1,"dt":[15,2,0,0,25,2,0,0,27,1],"id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","args":["","{","\"","what","\"",": ","\"","t","emporary","\"","}"]}} +{"type":"assistant/chunk","seq":99,"time":1785157565490,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."}}}} +{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}} +{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}} +{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"} +{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}} +{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"} +{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":109,"time0":1785157566525,"data":{"turn":1,"step":2,"index":0,"dt":[105,30,2,0,0,24,2,0,0,27,2,1,0,25,0,0,0,1,0,39,1],"texts":["Good",","," no"," temporary"," plugins"," running","."," Now"," step"," ","2",":"," call"," cord","is","_m","ount"," with"," the"," exact"," code","."]}} +{"type":"assistant/chunk","seq":131,"time":1785157566845,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":132,"time0":1785157566845,"data":{"turn":1,"step":2,"index":1,"dt":[42,4,1,0,0,15,2,0,0,0,16,0,0,0,0,1,23,2,0,0,25,7,18],"id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","args":["","{","\"","code","\"",": ","\"","return"," {"," name",":"," \\\"","sn","apshot","-no","op","\\\","," apply","(ctx",")"," {}"," }","\"","}"]}} +{"type":"assistant/chunk","seq":156,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."}}}} +{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}} +{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"} +{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}} +{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"} +{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":166,"time0":1785157567836,"data":{"turn":1,"step":3,"index":0,"dt":[83,25,2,0,0,26,1,1,0,0,0,41,2,0,0,0,0,8,2,0,0,0],"texts":["The"," id"," is"," \"","d","yn","-","1","\"."," Now"," step"," ","3",":"," call"," cord","is","_un","mount"," with"," that"," id","."]}} +{"type":"assistant/chunk","seq":189,"time":1785157568117,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":190,"time0":1785157568118,"data":{"turn":1,"step":3,"index":1,"dt":[17,2,1,24,2,0,1,25,0,0,0,29],"id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","args":["","{","\"","id","\"",": ","\"","d","yn","-","1","\"","}"]}} +{"type":"assistant/chunk","seq":203,"time":1785157568278,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."}}}} +{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"} +{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"} +{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":213,"time0":1785157569186,"data":{"turn":1,"step":4,"index":0,"dt":[165,26,26,1,0,27,9,17,2,1,0,0,0,44,1,0,0,1,0,6,3],"texts":["All"," three"," calls"," succeeded","."," I"," should"," now"," reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":235,"time":1785157569515,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":236,"time0":1785157569515,"data":{"turn":1,"step":4,"index":1,"dt":[0,0,35,1,0,0],"texts":["C","ORD","IS","_","UI","_D","ONE"]}} +{"type":"assistant/chunk","seq":243,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}} +{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"} +{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 8d0d25f2dd..bad7b902c8 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -6,7 +6,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_try with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_stop with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop." +- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop." - button "复制": - img - button "在新对话中分支": @@ -19,22 +19,25 @@ - button: - img - text: Inspect temporary +- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': + - img + - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." - button [expanded]: - img -- text: Try temporary Plugin typescript +- text: Mount temporary Plugin typescript - button "复制" - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" -- button "Think The cordis_try returned \"dyn-1\" as the temporary plugin id. Now I need to call cordis_stop with that exact id.": +- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': - img - - text: Think The cordis_try returned "dyn-1" as the temporary plugin id. Now I need to call cordis_stop with that exact id. + - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." - button: - img -- text: Stop temporary Plugin dyn-1 -- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\".": +- text: Unmount temporary Plugin dyn-1 +- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": - img - - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE". + - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE -- text: cache hit 71% · 50,140 tokens · 1 turns · 3 steps +- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a09ed98714..995a8c669d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_stop`, `cordis_try` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -207,7 +207,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `cordis_inspect` -Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. +Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. ```json { @@ -235,30 +235,9 @@ Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -### `cordis_stop` +### `cordis_mount` -Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. - -```json -{ - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart." - } - }, - "required": [ - "id" - ] -} -``` - -Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) - -### `cordis_try` - -Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -277,7 +256,28 @@ Try a temporary Cordis Plugin in the current DSH process. This creates an in-mem Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes. +### `cordis_unmount` + +Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart." + } + }, + "required": [ + "id" + ] +} +``` + +Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) + +Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 39822b99cc..f5b73d9b6d 100644 --- a/examples/README.i18n.yaml +++ b/examples/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 examples/README.md -README.md: 8c9bf449bc0ec4f857fae3c7775ed2cb8febbad8 -README.zh.md: 2baba679de10a5e52b719e1bbe1900a792610777 +README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f +README.zh.md: 72ab92602d0a53cabdbfa8bc34838061df25d1c7 diff --git a/examples/README.md b/examples/README.md index 8c9bf449bc..7f12178d1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,7 +22,7 @@ An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foregr ## cordis-agent -The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_try` / `cordis_stop`) let the agent inspect the current DSH process, try model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and stop them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. +The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. Run the TUI with `pnpm run demo:cordis`, the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis web`, or the ACP server with `pnpm run demo:cordis acp` (all need `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. diff --git a/examples/README.zh.md b/examples/README.zh.md index 2baba679de..72ab92602d 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -22,7 +22,7 @@ ## cordis-agent -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_try`/`cordis_stop`)使 agent 可以检查当前 DSH 进程、尝试模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次停止它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 301391a4dd..aae2454dbd 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -5,13 +5,13 @@ {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,8 +22,8 @@ {"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":22,"time":1785036891166,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}} -{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785036891171,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785036891175,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -47,13 +47,13 @@ {"type":"step/end","seq":45,"time":1785036891786,"data":{"turn":1,"step":4}} {"type":"step/start","seq":46,"time":1785036891789,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":51,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785036891796,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785036891796,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"assistant/message","seq":52,"time":1785036891796,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1785036891796,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785036891799,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785036891801,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index ea24846b01..58a54a1509 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -56,23 +56,23 @@ interface ToolArgsMap { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; } & Record; - /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ + /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ cordis_inspect: { /** Limit the report to one section. Omit for all sections. */ what?: "services" | "plugins" | "tools" | "temporary" | "api" | "events"; /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record; - /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */ - cordis_stop: { - /** The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart. */ - id: string; - } & Record; - /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ - cordis_try: { + /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + cordis_mount: { /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */ code: string; } & Record; + /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */ + cordis_unmount: { + /** The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart. */ + id: string; + } & Record; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ create_goal: { /** The concrete completion objective inferred from the direct human request. */ @@ -248,17 +248,17 @@ interface ToolOutputMap { }; }; cordis_inspect: string; - cordis_stop: { - id: string; - pluginName: string; - }; - cordis_try: { + cordis_mount: { id: string; pluginName: string; state: "pending" | "loading" | "active" | "failed" | "disposed" | "unloading"; provides: string[]; waitingFor: string[]; }; + cordis_unmount: { + id: string; + pluginName: string; + }; create_goal: { goal: null; } | { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index dd9558621b..314b24e2be 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -47,7 +47,7 @@ }, { "name": "cordis_inspect", - "description": "Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", + "description": "Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", "parameters": { "type": "object", "properties": { @@ -71,24 +71,8 @@ } }, { - "name": "cordis_stop", - "description": "Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.", - "parameters": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart." - } - }, - "required": [ - "id" - ] - } - }, - { - "name": "cordis_try", - "description": "Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "name": "cordis_mount", + "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", "parameters": { "type": "object", "properties": { @@ -102,6 +86,22 @@ ] } }, + { + "name": "cordis_unmount", + "description": "Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart." + } + }, + "required": [ + "id" + ] + } + }, { "name": "create_goal", "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 0f08c18245..d291e1b180 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-8398dc5565aa/cad9e2509e5d-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5e53dc8acfe4/2ce9d7a31a38-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index dd5e167660..1bf8db5dba 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"d7e97e44-02ae-486e-8fb9-17a1b080a3ed","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"d7e97e44-02ae-486e-8fb9-17a1b080a3ed","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"71b8e33b-4358-4688-a626-cae0f73cd9cb","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"71b8e33b-4358-4688-a626-cae0f73cd9cb","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index c12398b099..f78886662d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":152,"time":1784821263293,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151],"surfaceOp":"append"} {"type":"tool/call","seq":153,"time":1784821263294,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"f9e0a1a7-7864-4397-a1df-c1ce37f41426","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"f9e0a1a7-7864-4397-a1df-c1ce37f41426","outcome":"rejected"}} +{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"20a27d8b-d8c6-4620-b314-3d68747f68b2","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":155,"time":1784821263301,"data":{"id":"20a27d8b-d8c6-4620-b314-3d68747f68b2","outcome":"rejected"}} {"type":"tool/result","seq":156,"time":1784821263302,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[153],"surfaceOp":"append"} {"type":"step/end","seq":157,"time":1784821263307,"data":{"turn":1,"step":1}} {"type":"step/start","seq":158,"time":1784821263307,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index d294525484..536aa72aea 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"9439e616-fcb2-498e-a7ee-0488c378bccf","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"9439e616-fcb2-498e-a7ee-0488c378bccf","outcome":"allowed-once"}} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"ffef80ac-8819-4d23-943f-71cea3fdf01e","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"ffef80ac-8819-4d23-943f-71cea3fdf01e","outcome":"allowed-once"}} {"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 82397bcff1..7d1a4162db 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -16,8 +16,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"5583b42d-96b8-4a43-9064-12fe05f62a24","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"5583b42d-96b8-4a43-9064-12fe05f62a24","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"3c30c6d5-3b49-4e3a-9ed1-fc94f9fb39bb","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"3c30c6d5-3b49-4e3a-9ed1-fc94f9fb39bb","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index e540dfbd83..10e395e38a 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785140680525,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36007 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-5ca5c1bc368d/3127fda07f8f-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785157642983,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36007 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-0a508d3a5c8b/adedf3ca051a-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml index 6ac654d851..f9a03c46fa 100644 --- a/examples/cordis-agent/README.i18n.yaml +++ b/examples/cordis-agent/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 examples/cordis-agent/README.md -README.md: 45c7d96f672a6dc7703d750f46e2fee08966adfa -README.zh.md: c9991dea8840e1c046f3e0861e65c0d84a81634a +README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 +README.zh.md: c2873b6de96a8b47ad8ea4fb2cf03a7501406300 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 45c7d96f67..55970e932b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, try in-memory temporary Plugins, and stop them. Temporary Plugins remain active across turns but disappear on stop, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it @@ -18,20 +18,20 @@ pnpm run demo:cordis acp # ACP server The intended demo is staged — verify the listener link first, then let the agent extend itself: ``` -> Try a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) [cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Stop both temporary Plugins. - [tool call] cordis_stop({"id": "dyn-1"}) +> Unmount both temporary Plugins. + [tool call] cordis_unmount({"id": "dyn-1"}) ``` -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and try two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. +Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model tries a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index c9991dea88..c2873b6de9 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、尝试仅存于内存的临时 Plugin,并再次停止它们。临时 Plugin 可跨 turn 保持活跃,但会在 stop、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -18,20 +18,20 @@ pnpm run demo:cordis acp # ACP server 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: ``` -> Try a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_try({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until stopped or DSH restarts). +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. + [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) [cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_try({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) + [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Stop both temporary Plugins. - [tool call] cordis_stop({"id": "dyn-1"}) +> Unmount both temporary Plugins. + [tool call] cordis_unmount({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可尝试两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型尝试一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 9ef98c12a4..6d65d1ea38 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -3,7 +3,7 @@ # Cordis Agent App Composition -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and try or stop in-memory temporary Plugins. +The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. ```mermaid flowchart LR diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index c4644baa53..94b3ddbc58 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,6 +1,6 @@ # Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/temporary/API/event state, try a model-written temporary -# Plugin, and quiescently stop it. The app bin loads the gitignored +# service/plugin/tool/temporary/API/event state, mount a model-written temporary +# Plugin, and quiescently unmount it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. # Trust stance: the vm and context façade limit accidental global/framework # access but are not a security boundary; temporary Plugin code reaches live capabilities @@ -60,7 +60,7 @@ persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, try a temporary listener, or invent a temporary tool for itself.' + welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the {{model}} model. @@ -68,15 +68,15 @@ You run INSIDE a cordis plugin runtime, and your cordis_* tools operate on that live runtime: cordis_inspect to look around (its `api` and `events` sections document the service methods, type shapes, and events - your Plugin code can use), cordis_try to try an in-memory temporary + your Plugin code can use), cordis_mount to mount an in-memory temporary Plugin (an event listener, a brand-new tool for yourself, or a service - another temporary Plugin injects), cordis_stop to clean one up. These - Plugins remain across turns but disappear on stop, toolset unload, or + another temporary Plugin injects), cordis_unmount to clean one up. These + Plugins remain across turns but disappear on unmount, toolset unload, or DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node built-ins (require/setTimeout/fetch) — use the runtime's cordis services via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small single-purpose plugins, prefer plain notification events over waterfall - events unless you intend to intercept, and stop what you no longer + events unless you intend to intercept, and unmount what you no longer need. Report results briefly. # The self-referential cordis toolset (loaded after the app so ctx.tools exists). diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 9402f1bba5..66cab37258 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -8,7 +8,7 @@ const testToolSignal = new AbortController().signal /** * With-key smoke for the self-referential cordis tools: a REAL model drives - * cordis_try/cordis_stop against the live context the test observes. + * cordis_mount/cordis_unmount against the live context the test observes. * World-verified, not self-reported: the mounted listener must actually WRITE * its tagged console line, the self-made tool must actually EXIST in the * registry and appear as a real `tool/call`, the cross-mount service must @@ -37,14 +37,14 @@ function resultText(result: { content: { type: string; text?: string }[] }): str } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('tries a temporary status listener whose tagged output actually fires, then stops it', async () => { + it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.followup([{ type: 'text', - text: 'Use cordis_try to create a temporary Plugin that listens to the \'agent/status\' ' + text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' + 'Cordis event and logs every change with console.log. Reply "running" once done.', }]) await waitForIdle(ctx, agent) @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif }) expect(resultText(mid)).toContain('dyn-') - agent.followup([{ type: 'text', text: 'Now stop the temporary Plugin you just tried.' }]) + agent.followup([{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }]) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ @@ -74,7 +74,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif agent.followup([{ type: 'text', - text: 'Give yourself a new tool: use cordis_try to create a temporary Plugin with ' + text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'to register a tool named reverse_text with one required string parameter ' + '"text", returning the text reversed. Then CALL reverse_text with the ' @@ -88,7 +88,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(ctx.tools.get('reverse_text')).toBeDefined() const events = [...agent.session.events] const calls = events.filter(event => event.type === 'tool/call') - expect(calls.some(event => event.data.name === 'cordis_try')).toBe(true) + expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true) const reverseCalls = calls.filter(event => event.data.name === 'reverse_text') expect(reverseCalls.length).toBeGreaterThan(0) const reverseResults = events @@ -98,7 +98,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif // On failure, surface what the model actually mounted and what the tool // returned — an e2e failing at a distance is undebuggable without it. const mountCode = calls - .filter(event => event.data.name === 'cordis_try') + .filter(event => event.data.name === 'cordis_mount') .map(event => event.data.arguments) .join('\n---\n') const trace = events.map((event) => { @@ -115,13 +115,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ).toBe(true) }, 120_000) - it('composes two temporary Plugins through provide/inject, and stopping the provider parks the consumer', async () => { + it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.followup([{ type: 'text', - text: 'Try TWO separate temporary Plugins with cordis_try. First a provider: apply calls ' + text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' + 'a tool named shout_text with one required string parameter "text" whose execute returns ' @@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.followup([{ type: 'text', text: 'Now stop ONLY the provider temporary Plugin (the one that provided shouter).' }]) + agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }]) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index c6cc7e30fb..2e12cd76c3 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -15,8 +15,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_try to try a temporary Plugin, cordis_stop ' - + 'to stop one. Follow the tool descriptions exactly and report results briefly.' + + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' + + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' export async function cordisHarness(): Promise { const ctx = new Context() diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 9504e21d86..118c53d183 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -248,21 +248,21 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { expect(ctx.tasks.list()).toEqual([]) }, 15_000) - it('uses cordis_try DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => { + it('uses cordis_mount DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => { ctx = await typedCodeModeHarness() await ctx.plugin(ToolCordis) const value = completion(await runCode(ctx, ` - const active = await tools.cordis_try({ + const active = await tools.cordis_mount({ code: "return { name: 'active-code-mode-plugin', apply(ctx) {} }", }); - const pending = await tools.cordis_try({ + const pending = await tools.cordis_mount({ code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }", }); const before = await tools.cordis_inspect({ what: 'temporary' }); - const stopped = await tools.cordis_stop({ id: active.id }); + const stopped = await tools.cordis_unmount({ id: active.id }); const after = await tools.cordis_inspect({ what: 'temporary' }); - await tools.cordis_stop({ id: pending.id }); + await tools.cordis_unmount({ id: pending.id }); return { active, pending, diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 1f777928eb..9c9dc2c2ce 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index d93afe1808..618a6f1af7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index fbdd9dc6f9..996d9a81aa 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -3,15 +3,15 @@ {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_stop: {\n /** The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart. */\n id: string;\n } & Record;\n /** Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_try: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_stop: {\n id: string;\n pluginName: string;\n };\n cordis_try: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_stop","description":"Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_try (for example \"dyn-1\"); valid only in this process and invalid after stop or restart."}},"required":["id"]}},{"name":"cordis_try","description":"Try a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_stop, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider stops. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider stops. Everything registered inside `apply` is cleaned up automatically by cordis_stop. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when stopped) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":8,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,8 +22,8 @@ {"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}} -{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785037378917,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785037378920,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -47,13 +47,13 @@ {"type":"step/end","seq":45,"time":1785037379529,"data":{"turn":1,"step":4}} {"type":"step/start","seq":46,"time":1785037379531,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":51,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} -{"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} +{"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785037379536,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785037379538,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 503ea5b0be..339595168d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -4,13 +4,13 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -21,8 +21,8 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -46,13 +46,13 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":45,"time":0,"data":{"turn":1,"step":4}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":46,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index d2290421f2..b9ce1d12da 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -4,13 +4,13 @@ {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -45,13 +45,13 @@ {"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a4f6159eea..41f31f4a73 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -29,11 +29,11 @@ buffer 11| 12| "▌ " style 0-0 fg=green -13| "▌ ✓ Try temporary Cordis Plugin " +13| "▌ ✓ Mount temporary Cordis Plugin " style 0-0 fg=green style 2-2 fg=green bold - style 3-30 bold -14| "▌ Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until stopped or DSH " + style 3-32 bold +14| "▌ Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " style 0-0 fg=green 15| "▌ restarts). " style 0-0 fg=green @@ -50,7 +50,7 @@ buffer style 0-0 fg=green 21| "▌ - Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime:" style 0-0 fg=green -22| "▌ until stopped or DSH restarts " +22| "▌ until unmounted or DSH restarts " style 0-0 fg=green 23| "▌ " style 0-0 fg=green @@ -87,11 +87,11 @@ buffer 38| 39| "▌ " style 0-0 fg=green -40| "▌ ✓ Stop temporary Cordis Plugin dyn-1 " +40| "▌ ✓ Unmount temporary Cordis Plugin dyn-1 " style 0-0 fg=green style 2-2 fg=green bold - style 3-37 bold -41| "▌ Temporary Plugin dyn-1 was stopped and removed. " + style 3-40 bold +41| "▌ Temporary Plugin dyn-1 was unmounted and removed. " style 0-0 fg=green 42| "▌ " style 0-0 fg=green diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 8eaba618b2..1f2989f183 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -122,7 +122,7 @@ const SCENARIOS: Scenario[] = [ { name: 'cordis-dynamic-toolchain', composition: 'advanced', - expectedTools: ['cordis_try', 'run_code', 'subagent', 'workflow', 'cordis_stop'], + expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'], expectedEventCounts: { 'tool/code-dispatch': 1 }, childSessions: 2, recorded: false, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e4a41bcc0b..5d2abeb7da 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 4e12b82070bd27a0724cc3e43e952a4221339f49 -README.zh.md: b4936501b68e110ff538fec28bb52a3a72d7b849 +README.md: 32651291253077098bc43a930cf4ce11d29b1ed8 +README.zh.md: ea8f398541d7af6136b29c3365a78c4ea3a1e85d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4e12b82070..3265129125 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Try temporary Plugin`, and `Stop temporary Plugin` with a shared Cordis accent; try keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b4936501b6..ea8f398541 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Try temporary Plugin` 和 `Stop temporary Plugin`;try 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 7c78938196..5b725df00b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -37,15 +37,15 @@ const TOOL_VARIANTS: Record = { edit: 'edit', run_code: 'code', cordis_inspect: 'read', - cordis_try: 'code', - cordis_stop: 'others', + cordis_mount: 'code', + cordis_unmount: 'others', } /** Tool-owned titles that refine a generic row variant without replacing it. */ const TOOL_TITLES: Record = { cordis_inspect: 'Inspect', - cordis_try: 'Try temporary Plugin', - cordis_stop: 'Stop temporary Plugin', + cordis_mount: 'Mount temporary Plugin', + cordis_unmount: 'Unmount temporary Plugin', } /** diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 003a3e6b13..81487cc3da 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -172,21 +172,21 @@ describe('run_code sub-calls through the real chat machinery', () => { const code = 'return { name: "audit", apply(ctx) {} }' const dispatches = new Map([[parent, [ subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), - subCall(12, parent, 2, 'cordis_try', { code }, 'Temporary Plugin dyn-2 is running'), - subCall(13, parent, 3, 'cordis_stop', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was stopped and removed.'), + subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'), + subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) const view = mountApp(b.slots) const nest = view.container.querySelector('[data-subcalls]')! expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') - const tried = nest.querySelector('[data-variant="code"]') - expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) - expect(nest.querySelector('[data-tool="cordis_stop"]')?.textContent) - .toContain('Stop temporary Plugindyn-2') + const mounted = nest.querySelector('[data-variant="code"]') + expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) + expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent) + .toContain('Unmount temporary Plugindyn-2') - fireEvent.click(tried!.querySelector('button[aria-expanded]')!) - expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) }) it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 5c0e8d6337..13a74548b4 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -32,8 +32,8 @@ describe('tool-call-model', () => { expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') expect(classifyTool('cordis_inspect')).toBe('read') - expect(classifyTool('cordis_try')).toBe('code') - expect(classifyTool('cordis_stop')).toBe('others') + expect(classifyTool('cordis_mount')).toBe('code') + expect(classifyTool('cordis_unmount')).toBe('others') expect(classifyTool('todo_write')).toBe('others') }) @@ -80,20 +80,20 @@ describe('tool-call-model', () => { title: 'Inspect', summary: 'api', }) - expect(toolRowModel('cordis_try', running({ - name: 'cordis_try', + expect(toolRowModel('cordis_mount', running({ + name: 'cordis_mount', argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}', }))).toMatchObject({ variant: 'code', - title: 'Try temporary Plugin', + title: 'Mount temporary Plugin', summary: 'return { name: "audit", apply(ctx) {} }', body: 'return { name: "audit", apply(ctx) {} }', }) - expect(toolRowModel('cordis_stop', result({ - call: { name: 'cordis_stop', argsRaw: '{"id":"dyn-2"}' }, + expect(toolRowModel('cordis_unmount', result({ + call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' }, }))).toMatchObject({ variant: 'others', - title: 'Stop temporary Plugin', + title: 'Unmount temporary Plugin', summary: 'dyn-2', }) }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index f1a6c6da17..0825690430 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -166,19 +166,19 @@ describe('keyed toolview hole through the real machinery', () => { const code = 'return { name: "audit", apply(ctx) {} }' const b = await bench([ toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'), - toolResult(4, 'cordis-2', 'cordis_try', JSON.stringify({ code })), - toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'), + toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })), + toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'), ]) const view = mountApp(b.slots) expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') - const tried = view.container.querySelector('[data-variant="code"]') - expect(tried?.textContent).toContain(`Try temporary Plugin${code}`) - expect(view.container.querySelector('[data-tool="cordis_stop"]')?.textContent) - .toContain('Stop temporary Plugindyn-2') + const mounted = view.container.querySelector('[data-variant="code"]') + expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) + expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent) + .toContain('Unmount temporary Plugindyn-2') - fireEvent.click(tried!.querySelector('button[aria-expanded]')!) - expect(tried!.querySelector('pre.shiki')?.textContent).toBe(code) + fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) }) it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => { diff --git a/packages/cordis/README.i18n.yaml b/packages/cordis/README.i18n.yaml index 53fe781704..fbc8586428 100644 --- a/packages/cordis/README.i18n.yaml +++ b/packages/cordis/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/cordis/README.md -README.md: bcd11230cdaaaf2893bcc64eadfe689855bc3553 -README.zh.md: c26424f08dced26aa60d0a21202449e1b3b16860 +README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365 +README.zh.md: cc91e68f0dfa9fb343c332eba2579c2a077beb64 diff --git a/packages/cordis/README.md b/packages/cordis/README.md index bcd11230cd..a47b9ba207 100644 --- a/packages/cordis/README.md +++ b/packages/cordis/README.md @@ -6,4 +6,4 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in | Package | Role | ctx key | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_try` / `cordis_stop` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` | diff --git a/packages/cordis/README.zh.md b/packages/cordis/README.zh.md index c26424f08d..cc91e68f0d 100644 --- a/packages/cordis/README.zh.md +++ b/packages/cordis/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向模型、作用于 agent(智能体)所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并尝试或停止仅存于内存的临时 Plugin。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +面向模型、作用于 agent(智能体)所在实时 Cordis 运行时的工具:检查当前 DSH 进程,并挂载或卸载仅存于内存的临时 Plugin。设计归档见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 | 包(package) | 角色 | ctx 键 | |---|---|---| -| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_try`/`cordis_stop` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` | +| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理临时 Plugin | 注册到 `ctx.tools` | diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 684fa672bf..64d5ac7711 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/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/cordis/tool-cordis/README.md -README.md: fda296817026556f235d42626e87fe1f361f2c36 -README.zh.md: b8e3c02ba43a7c366664f5964168798ee7356186 +README.md: 5b58e665dae95aea0d0ad094238fef5d3dc0fb97 +README.zh.md: 11e5be11dca84247b19888fefa7d70e5d74da174 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index fda2968170..5b58e665da 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -6,15 +6,15 @@ The self-referential Cordis toolset: three model-facing tools over the live runt ## What it does -- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_try` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. -- `cordis_try` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-`. -- `cordis_stop` — stops one `dyn-` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins. +- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, the `cordis_mount` temporary-Plugin subset, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. +- `cordis_mount` — evaluates model-written JavaScript now and saves it nowhere; the code must return an in-memory temporary Plugin tracked as `dyn-`. +- `cordis_unmount` — unmounts one `dyn-` temporary Plugin and returns only after its owned effects reach quiescence. It cannot remove Loader, configured, or installed Plugins. Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md). -Canonical successes are the inspection string, try `{ id, pluginName, state, provides, waitingFor }`, and stop `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until stopped or DSH restarts; stop confirms that it was stopped and removed. +Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed. -Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_stop`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. +Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ## Trust stance @@ -32,7 +32,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab ## Rendering -All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_try` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. +All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_mount` carries the temporary-Plugin code as `rawInput`. Presenters are pure functions of the args; results keep the default text rendering. ## Export shape @@ -44,7 +44,7 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau #### What the model sees -The conversation model sees the generated [`cordis_inspect`, `cordis_try`, and `cordis_stop` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. +The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. #### Token effect @@ -58,7 +58,7 @@ Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle cha #### What the model sees -Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until stopped or DSH restart. The empty state explains that `cordis_try` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Try returns `Temporary Plugin is running (...)` or `Temporary Plugin is pending (...)`; stop returns `Temporary Plugin was stopped and removed.` The submitted program remains in assistant tool-call history. +Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Temporary Plugins` heading. Each temporary-Plugin row reports running/pending state, provided and awaited services, and its lifetime until unmounted or DSH restart. The empty state explains that `cordis_mount` Plugins disappear on restart. Broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `Temporary Plugin is running (...)` or `Temporary Plugin is pending (...)`; unmount returns `Temporary Plugin was unmounted and removed.` The submitted program remains in assistant tool-call history. #### Token effect @@ -68,11 +68,11 @@ Inspect output and mount code are data-dependent and resent until compaction; li Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. -### Later requests after cordis_try +### Later requests after cordis_mount #### What the model sees -A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` removes those contributions after quiescence. +A temporary Plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_unmount` removes those contributions after quiescence. #### Token effect @@ -80,7 +80,7 @@ Indirect token impact equals the temporary Plugin's contributions and lasts only #### KV Cache effect -Trying or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable. +Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged temporary-Plugin set remains prefix-stable. ## Known Limitations and Deferred Work diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index b8e3c02ba4..11e5be11dc 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -6,15 +6,15 @@ ## 功能 -- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_try` 临时 Plugin 子集,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 -- `cordis_try`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-` 跟踪、仅存于内存的临时 Plugin。 -- `cordis_stop`:停止一个 `dyn-` 临时 Plugin,并只在其自有效果完全停稳后返回;它不能删除 Loader、配置或已安装的 Plugin。 +- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活 Plugin fiber、已注册工具、`cordis_mount` 临时 Plugin 子集,以及目录支持的 `api`/`events` 参考。精确的 `name` 配合 `what: "api"` 或 `what: "events"` 可缩窄报告,并附上原始源代码 JSDoc。 +- `cordis_mount`:立即求值模型编写的 JavaScript 且不保存到任何位置;代码必须返回一个以 `dyn-` 跟踪、仅存于内存的临时 Plugin。 +- `cordis_unmount`:卸载一个 `dyn-` 临时 Plugin,并只在其自有效果完全停稳后返回;它不能删除 Loader、配置或已安装的 Plugin。 精确的面向模型 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。 -规范成功值分别为检查字符串、尝试 `{ id, pluginName, state, provides, waitingFor }`,以及停止 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被停止或 DSH 重启;停止结果确认它已停止并移除。 +规范成功值分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生 renderer 会说明临时 Plugin 正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。 -临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session,但会在 `cordis_stop`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 +临时 Plugin 只存在于共享 DSH 进程内存中。它可跨后续 turn 保持活跃,也可能影响同一进程中的其他 session,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式 Plugin。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 ## 信任立场 @@ -32,7 +32,7 @@ ## 渲染 -三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_try` 以 `rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 +三个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_mount` 以 `rawInput` 携带临时 Plugin 代码。presenter 是 args 的纯函数;结果保留默认文本渲染。 ## 导出形状 @@ -44,7 +44,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### 模型看到的内容 -该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_try` 和 `cordis_stop` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 +该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_mount` 和 `cordis_unmount` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。 #### Token 影响 @@ -58,7 +58,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### 模型看到的内容 -检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 running/pending 状态、提供与等待的服务,以及持续至停止或 DSH 重启的生命周期;空状态说明 `cordis_try` Plugin 会在重启时消失。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。尝试结果为 `Temporary Plugin is running (...)` 或 `Temporary Plugin is pending (...)`;停止结果为 `Temporary Plugin was stopped and removed.`。提交的程序保留在 assistant 工具调用历史中。 +检查会精确地用 `##
` 加换行及数据相关主体来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Temporary Plugins` 标题。每个临时 Plugin 行都会报告 running/pending 状态、提供与等待的服务,以及持续至卸载或 DSH 重启的生命周期;空状态说明 `cordis_mount` Plugin 会在重启时消失。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"` 或 `what: "events"` 返回一个精确目标及其原始 JSDoc。挂载返回 `Temporary Plugin is running (...)` 或 `Temporary Plugin is pending (...)`;卸载返回 `Temporary Plugin was unmounted and removed.`。提交的程序保留在 assistant 工具调用历史中。 #### Token 影响 @@ -68,11 +68,11 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 -### cordis_try 后的后续请求 +### cordis_mount 后的后续请求 #### 模型看到的内容 -临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 会在完全停稳后移除这些贡献。 +临时 Plugin 可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_unmount` 会在完全停稳后移除这些贡献。 #### Token 影响 @@ -80,7 +80,7 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 #### KV Cache 影响 -尝试或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。 +挂载或卸载提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;临时 Plugin 集合不变时,前缀保持稳定。 ## 已知限制与暂缓事项 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 96043373ff..595dc462e0 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -684,7 +684,7 @@ function sandboxContext(ctx: Context): Context { if (ctx.get(prop) !== undefined) { throw new Error( `service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, ` - + 'so cordis parks this temporary Plugin if the provider later stops.', + + 'so cordis parks this temporary Plugin if the provider is later unmounted.', ) } throw new Error( diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 47b2755d34..5476dc0929 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -1,6 +1,6 @@ /** - * Self-referential runtime tools: inspect live services/plugins/tools, try a returned temporary - * plugin under an owned dynamic fiber, and stop it to quiescence. Registrations are fiber effects, + * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned temporary + * plugin under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, * so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent * accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real * runtime. Named exports preserve loader injection metadata. @@ -15,7 +15,7 @@ import { isPlugin, pluginName } from './guard.ts' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools, providedServices } from './inspect.ts' import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' -import { presentInspectCall, presentStopCall, presentTryCall } from './present.ts' +import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' import { createSandbox, evaluateMountCode } from './sandbox.ts' export const name = 'tool-cordis' @@ -60,10 +60,10 @@ export function apply(ctx: Context, config: Config): void { + 'Sections: `services` (every provided ctx service and the plugin fiber that owns it), ' + '`plugins` (all live plugin fibers with their lifecycle states), ' + '`tools` (the model-facing tools currently registered, i.e. what you can call), ' - + '`temporary` (only temporary Plugins created by cordis_try: id, name, state, provided services, awaited services, and lifetime), ' + + '`temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), ' + '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), ' + '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). ' - + 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_stop, toolset unload, or DSH restart; they are not restored automatically. ' + + 'Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. ' + 'The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. ' + 'With `what:"api"` or `what:"events"`, pass an exact `name` ' + 'to narrow to one service/event and include its original source JSDoc.', @@ -106,11 +106,11 @@ export function apply(ctx: Context, config: Config): void { })) ctx.tools.register(defineTool({ - name: 'cordis_try', + name: 'cordis_mount', description: - 'Try a temporary Cordis Plugin in the current DSH process. ' + 'Mount a temporary Cordis Plugin in the current DSH process. ' + 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. ' - + 'It remains active across later turns until cordis_stop, toolset unload, or DSH restart. ' + + 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. ' + 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. ' + 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ' + 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. ' @@ -123,7 +123,7 @@ export function apply(ctx: Context, config: Config): void { + '— declares dependencies, and cordis activates the plugin only after the ' + 'services exist; PREFER this form. You may reach ONLY the services you list in ' + 'inject: an undeclared service throws even if it exists, because an undeclared ' - + 'dependency would not be cleaned up if its provider stops. ' + + 'dependency would not be cleaned up if its provider is unmounted. ' + 'BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists ' + 'method signatures AND the type shapes of their arguments/returns (do not guess a ' + 'field\'s type; e.g. a bash run\'s stdout is an object, not a string). ' @@ -140,8 +140,8 @@ export function apply(ctx: Context, config: Config): void { + '`output.render(args, value)` separately returns Native/model content blocks. ' + 'Temporary Plugins can COMPOSE: one Plugin may `ctx.provide(\'name\', value)` a service and ' + 'another may declare `inject: [\'name\']` to consume it — the consumer stays pending ' - + 'until the provider exists and returns to pending when the provider stops. ' - + 'Everything registered inside `apply` is cleaned up automatically by cordis_stop. ' + + 'until the provider exists and returns to pending when the provider is unmounted. ' + + 'Everything registered inside `apply` is cleaned up automatically by cordis_unmount. ' + 'Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness ' + 'terminal), `harness.defineTool`, `harness.registerTool`, ' + '`btoa`, `atob`, `TextEncoder`, `TextDecoder`. ' @@ -150,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { + 'errors; `process` and `Buffer` are undefined. Instead use inject: [\'fs\'] + ctx.fs for ' + 'files, inject: [\'web\'] + ctx.web for HTTP, inject: [\'bash\'] + ctx.bash for processes, ' + 'and inject: [\'timer\'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, ' - + 'auto-cleaned when stopped) — cordis_inspect what:"api" shows what THIS runtime provides. ' + + 'auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. ' + 'Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). ' + 'Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a ' + 'trailing `next` callback which MUST be called — returning without `next()` ' @@ -191,7 +191,7 @@ export function apply(ctx: Context, config: Config): void { : `is running (plugin "${value.pluginName}"` return [{ type: 'text', - text: `Temporary Plugin ${value.id} ${status}; available until stopped or DSH restarts).`, + text: `Temporary Plugin ${value.id} ${status}; available until unmounted or DSH restarts).`, }] }, }, @@ -226,19 +226,19 @@ export function apply(ctx: Context, config: Config): void { waitingFor: missing, } }, - presentCall: presentTryCall, + presentCall: presentMountCall, })) ctx.tools.register(defineTool({ - name: 'cordis_stop', + name: 'cordis_unmount', description: - 'Stop a current-process temporary Plugin created by cordis_try. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. ' + 'Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. ' + 'Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.', parameters: { id: { type: 'string', required: true, - description: 'The temporary Plugin id returned by cordis_try (for example "dyn-1"); valid only in this process and invalid after stop or restart.', + description: 'The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart.', }, }, output: { @@ -250,7 +250,7 @@ export function apply(ctx: Context, config: Config): void { pluginName: { type: 'string', required: true }, }, }, - render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was stopped and removed.` }], + render: (_args, value) => [{ type: 'text', text: `Temporary Plugin ${value.id} was unmounted and removed.` }], }, async execute(args) { const mount = mounts.get(args.id) @@ -261,6 +261,6 @@ export function apply(ctx: Context, config: Config): void { mounts.delete(args.id) return { id: args.id, pluginName: mount.pluginName } }, - presentCall: presentStopCall, + presentCall: presentUnmountCall, })) } diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index b56546e512..cc0f3a4cda 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -100,13 +100,13 @@ export function describeTools(ctx: Context, scope?: ScopeKey): string[] { */ export function describeDynamic(ctx: Context, mounts: ReadonlyMap): string[] { if (mounts.size === 0) { - return ['No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.'] + return ['No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.'] } return [...mounts].map(([id, mount]) => { const provides = providedServices(ctx, mount.fiber) const waiting = missingServices(ctx, mount.fiber) const state = mount.fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[mount.fiber.state] - return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until stopped or DSH restarts` + return `- Temporary Plugin ${id}: ${mount.pluginName} [${state}] — provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}; lifetime: until unmounted or DSH restarts` }) } diff --git a/packages/cordis/tool-cordis/src/mount.ts b/packages/cordis/tool-cordis/src/mount.ts index e18e5c07ed..942d45eb79 100644 --- a/packages/cordis/tool-cordis/src/mount.ts +++ b/packages/cordis/tool-cordis/src/mount.ts @@ -39,8 +39,8 @@ export async function mountDynamic(group: Fiber, plugin: Plugin): Promise // while the old mount still holds the name — teach the replace recipe. if (message.includes('already registered')) { throw new Error( - `${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_stop that Plugin's id ` - + '(find it with cordis_inspect what:"temporary"), then try the new version.', + `${message} — to REPLACE something an earlier temporary Plugin registered, first cordis_unmount that Plugin's id ` + + '(find it with cordis_inspect what:"temporary"), then mount the new version.', ) } throw error instanceof Error ? error : new Error(message) diff --git a/packages/cordis/tool-cordis/src/present.ts b/packages/cordis/tool-cordis/src/present.ts index 35796378ca..89e1e3b635 100644 --- a/packages/cordis/tool-cordis/src/present.ts +++ b/packages/cordis/tool-cordis/src/present.ts @@ -25,28 +25,28 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene } /** - * The `cordis_try` call card: an execute carrying the temporary-plugin code as raw input. + * The `cordis_mount` call card: an execute carrying the temporary-plugin code as raw input. * @param args - the validated call arguments. * @returns the generic call card. */ -export function presentTryCall(args: { code: string }): GenericCallView { +export function presentMountCall(args: { code: string }): GenericCallView { return { card: 'generic', kind: 'execute', - title: 'Try temporary Cordis Plugin', + title: 'Mount temporary Cordis Plugin', rawInput: { code: args.code }, } } /** - * The `cordis_stop` call card: a delete, titled with the temporary-plugin id. + * The `cordis_unmount` call card: a delete, titled with the temporary-plugin id. * @param args - the validated call arguments. * @returns the generic call card. */ -export function presentStopCall(args: { id: string }): GenericCallView { +export function presentUnmountCall(args: { id: string }): GenericCallView { return { card: 'generic', kind: 'delete', - title: `Stop temporary Cordis Plugin ${args.id}`, + title: `Unmount temporary Cordis Plugin ${args.id}`, } } diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 5fc3be98d6..3c99c7a770 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -1,5 +1,5 @@ /** - * The `node:vm` sandbox `cordis_try` code evaluates in: a fresh realm whose globals are a + * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a * tagged write-through console, the `harness` registration helpers, the encoding primitives a * bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately * withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`, @@ -52,7 +52,7 @@ function patchDualRealmInstanceof(sandbox: object): void { const TIMER_REDIRECT = 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin ' - + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when stopped.' + + 'and call ctx.setTimeout / ctx.setInterval — those are fiber effects, cleaned up automatically when unmounted.' /** * The callable Node APIs the sandbox deliberately disables, each mapped to the @@ -87,7 +87,7 @@ function nodeApiTraps(): Record never> { } /** - * Build the vm context one `cordis_try` call evaluates in: the tagged + * Build the vm context one `cordis_mount` call evaluates in: the tagged * console, the `harness` registration helpers, the encoding primitives, the * Node-API traps, and the dual-realm `instanceof` patch, already * `createContext`-ed. diff --git a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts index 2772b216e0..cf89828aee 100644 --- a/packages/cordis/tool-cordis/tests/cross-mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/cross-mount.spec.ts @@ -11,10 +11,10 @@ import { call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, PROVIDER_CODE, setup, text } describe('cross-mount provide/inject', () => { it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => { const ctx = await setup() - const provider = await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + const provider = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) expect(text(provider)).toContain('is running') - const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) + const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) expect(text(consumer)).toContain('is running') @@ -27,39 +27,39 @@ describe('cross-mount provide/inject', () => { it('consumer first: stays pending naming the missing service, then activates when the provider mounts', async () => { const ctx = await setup() - const consumer = await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) + const consumer = await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) expect(consumer.isError).toBe(false) expect(text(consumer)).toContain('is pending') expect(text(consumer)).toContain('missing services: greeter') expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('waiting for: greeter') expect(ctx.tools.get('greet')).toBeUndefined() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) expect(ctx.tools.get('greet')).toBeDefined() expect(text(await call(ctx, 'greet', { name: 'late' }))).toBe('hi late') }) it('unmounting the provider sends the consumer back to pending and unwinds its registrations', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 expect(ctx.tools.get('greet')).toBeDefined() - const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) expect(ctx.tools.get('greet')).toBeUndefined() const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) - expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until stopped or DSH restarts') + expect(report).toContain('Temporary Plugin dyn-2: greeter-consumer [pending] — provides: none; waiting for: greeter; lifetime: until unmounted or DSH restarts') }) it('re-providing the service re-runs the consumer through the same guard (active again, tool back)', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 - await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(ctx.tools.get('greet')).toBeUndefined() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-3 + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-3 expect(ctx.tools.get('greet')).toBeDefined() expect(text(await call(ctx, 'greet', { name: 'again' }))).toBe('hi again') expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('Temporary Plugin dyn-2: greeter-consumer [running]') @@ -67,8 +67,8 @@ describe('cross-mount provide/inject', () => { it('a duplicate provide fails loud with the owning fiber named, and the failed mount is disposed', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) - const duplicate = await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) + const duplicate = await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) expect(duplicate.isError).toBe(true) expect(text(duplicate)).toContain('has been registered') const report = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) @@ -78,11 +78,11 @@ describe('cross-mount provide/inject', () => { it('inspect surfaces the linkage: provides on the provider row, the service in services and api sections', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) - await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) + await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) const dynamic = text(await call(ctx, 'cordis_inspect', { what: 'temporary' })) - expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until stopped or DSH restarts') + expect(dynamic).toContain('Temporary Plugin dyn-1: greeter-provider [running] — provides: greeter; waiting for: none; lifetime: until unmounted or DSH restarts') const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) expect(services).toContain('- greeter (provided by greeter-provider)') @@ -93,7 +93,7 @@ describe('cross-mount provide/inject', () => { it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => { const ctx = await setup() - const provider = await call(ctx, 'cordis_try', { + const provider = await call(ctx, 'cordis_mount', { code: ` return { name: 'answer-provider', @@ -106,7 +106,7 @@ describe('cross-mount provide/inject', () => { }) expect(provider.isError).toBe(false) - const consumer = await call(ctx, 'cordis_try', { + const consumer = await call(ctx, 'cordis_mount', { code: ` return { name: 'answer-consumer', @@ -132,9 +132,9 @@ describe('cross-mount provide/inject', () => { it('unmounting the consumer leaves the provider and its service intact', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: PROVIDER_CODE }) // dyn-1 - await call(ctx, 'cordis_try', { code: CONSUMER_CODE }) // dyn-2 - await call(ctx, 'cordis_stop', { id: 'dyn-2' }) + await call(ctx, 'cordis_mount', { code: PROVIDER_CODE }) // dyn-1 + await call(ctx, 'cordis_mount', { code: CONSUMER_CODE }) // dyn-2 + await call(ctx, 'cordis_unmount', { id: 'dyn-2' }) expect(ctx.tools.get('greet')).toBeUndefined() const services = text(await call(ctx, 'cordis_inspect', { what: 'services' })) diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 9406478f4e..2b3b32aee8 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -27,8 +27,8 @@ describe('cordis_inspect', () => { expect(report).toContain('- tools (provided by ToolRegistry)') expect(report).toContain('- tool-cordis [active]') expect(report).toContain('- cordis-dynamic [active]') - expect(report).toContain('- cordis_try') - expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_try disappear when DSH restarts.') + expect(report).toContain('- cordis_mount') + expect(report).toContain('No temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts.') }) it('limits the report to one section via `what`', async () => { @@ -42,10 +42,10 @@ describe('cordis_inspect', () => { it('shows a temporary Plugin in its exact section and in the flat plugins list', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) const report = text(await call(ctx, 'cordis_inspect', {})) expect(report).toContain('## Temporary Plugins') - expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until stopped or DSH restarts') + expect(report).toContain('- Temporary Plugin dyn-1: change-logger [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts') expect(report).toContain('- change-logger [active]') }) diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 3f983a5dff..77f4fa0995 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -40,9 +40,9 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { describe('cordis tools through the agent loop', () => { it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => { const adapter = new MockAdapter([ - toolCallResponse('call-1', 'cordis_try', { code: REVERSE_TOOL_CODE }, 'Extending myself.'), + toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'), toolCallResponse('call-2', 'reverse_text', { text: 'harness' }), - toolCallResponse('call-3', 'cordis_stop', { id: 'dyn-1' }), + toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }), textResponse('Done.'), ]) const ctx = await harness(adapter) @@ -53,7 +53,7 @@ describe('cordis tools through the agent loop', () => { const log = agent.session.events const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name) - expect(calls).toEqual(['cordis_try', 'reverse_text', 'cordis_stop']) + expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount']) const results = log.filter(event => event.type === 'tool/result') expect(results.map(event => event.data.isError)).toEqual([false, false, false]) @@ -67,22 +67,22 @@ describe('cordis tools through the agent loop', () => { expect(ctx.tools.get('reverse_text')).toBeUndefined() }) - it('keeps a temporary Plugin across turns, stops it, and does not restore it in a new runtime', async () => { + it('keeps a temporary Plugin across turns, unmounts it, and does not restore it in a new runtime', async () => { const adapter = new MockAdapter([ - toolCallResponse('try-1', 'cordis_try', { code: 'return { name: \'turn-marker\', apply() {} }' }), + toolCallResponse('mount-1', 'cordis_mount', { code: 'return { name: \'turn-marker\', apply() {} }' }), toolCallResponse('inspect-1', 'cordis_inspect', { what: 'temporary' }), textResponse('Turn one complete.'), toolCallResponse('inspect-2', 'cordis_inspect', { what: 'temporary' }), - toolCallResponse('stop-1', 'cordis_stop', { id: 'dyn-1' }), + toolCallResponse('unmount-1', 'cordis_unmount', { id: 'dyn-1' }), toolCallResponse('inspect-3', 'cordis_inspect', { what: 'temporary' }), textResponse('Turn two complete.'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-cordis-turn-lifetime'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'Try the marker and inspect it.' }]) + agent.followup([{ type: 'text', text: 'Mount the marker and inspect it.' }]) await waitForIdle(ctx, agent) - agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, stop it, then inspect again.' }]) + agent.followup([{ type: 'text', text: 'On this later turn, inspect the marker, unmount it, then inspect again.' }]) await waitForIdle(ctx, agent) const resultText = new Map( @@ -92,7 +92,7 @@ describe('cordis tools through the agent loop', () => { ) expect(resultText.get(CallId('inspect-1'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') expect(resultText.get(CallId('inspect-2'))).toContain('Temporary Plugin dyn-1: turn-marker [running]') - expect(resultText.get(CallId('stop-1'))).toBe('Temporary Plugin dyn-1 was stopped and removed.') + expect(resultText.get(CallId('unmount-1'))).toBe('Temporary Plugin dyn-1 was unmounted and removed.') expect(resultText.get(CallId('inspect-3'))).toContain('No temporary Plugins are running.') const restarted = await setup() diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index af5d51ffd3..a7b868381b 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -5,7 +5,7 @@ import { syntaxErrorContext } from '../src/sandbox.ts' import { call, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** - * The `cordis_try` success/failure family: real plugins land on a genuine + * The `cordis_mount` success/failure family: real plugins land on a genuine * cordis fiber tree, their registrations are observable through the real * registry/event bus, and every rejection path teaches the fix. */ @@ -14,7 +14,7 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('cordis_try', () => { +describe('cordis_mount', () => { it.each([ [42, 'options must be an object'], [{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'], @@ -47,9 +47,9 @@ describe('cordis_try', () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + const result = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected cordis_try success') + if (result.isError) throw new Error('expected cordis_mount success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger', @@ -57,7 +57,7 @@ describe('cordis_try', () => { provides: [], waitingFor: [], }) - expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until stopped or DSH restarts).') + expect(text(result)).toBe('Temporary Plugin dyn-1 is running (plugin "change-logger"; available until unmounted or DSH restarts).') // Fire a REAL tools/change by registering a tool; the mounted listener logs. ctx.tools.register(dummyTool('trigger_a')) @@ -66,16 +66,16 @@ describe('cordis_try', () => { it('mounts a bare-function plugin as , and a named function under its name', async () => { const ctx = await setup() - const anonymous = await call(ctx, 'cordis_try', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' }) + const anonymous = await call(ctx, 'cordis_mount', { code: 'return (ctx) => { ctx.on(\'tools/change\', () => {}) }' }) expect(anonymous.isError).toBe(false) expect(text(anonymous)).toContain('plugin ""') - const named = await call(ctx, 'cordis_try', { code: 'return function watcher(ctx) {}' }) + const named = await call(ctx, 'cordis_mount', { code: 'return function watcher(ctx) {}' }) expect(text(named)).toContain('plugin "watcher"') }) it('lets the agent give ITSELF a new tool, immediately callable through the registry', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) + const result = await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) expect(result.isError).toBe(false) expect(ctx.tools.schemas().map(schema => schema.name)).toContain('reverse_text') @@ -89,14 +89,14 @@ describe('cordis_try', () => { it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => { // VM-realm objects fail the session prototype-identity check; normalize them into host JSON. const ctx = await setup() - await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) const reversed = await call(ctx, 'reverse_text', { text: 'harness' }) expect(isJsonValue({ content: reversed.content, isError: reversed.isError })).toBe(true) }) it('projects presentation metadata from a dynamic canonical value', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'meta-return', @@ -136,7 +136,7 @@ describe('cordis_try', () => { ['undefined — a forgotten return', 'return undefined', 'execute result must be lossless JSON data'], ])('rejects an execute return of %s against its declared output', async (_label, returnStatement, diagnostic) => { const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'bad-return', @@ -162,7 +162,7 @@ describe('cordis_try', () => { it('does not echo a huge schema-invalid canonical value in the diagnostic', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'huge-return', @@ -189,7 +189,7 @@ describe('cordis_try', () => { // These common JSON-Schema spellings each have one DSL meaning, so normalize rather than // consume another model turn with a rejection. const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'json-schema-tool', @@ -245,7 +245,7 @@ describe('cordis_try', () => { // On an object PROPERTY, a JSON-Schema-style `required` array names the // required children — the nested unwrap converts it just like the top level. const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'nested-json-schema', @@ -276,7 +276,7 @@ describe('cordis_try', () => { it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'unified-schema', @@ -324,7 +324,7 @@ describe('cordis_try', () => { it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => { const ctx = await setup() const depth = 5_000 - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'deep-unified-schema', @@ -377,7 +377,7 @@ describe('cordis_try', () => { it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'raw-unified-schema', @@ -467,7 +467,7 @@ describe('cordis_try', () => { ['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'], ])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'bad-schema', @@ -508,7 +508,7 @@ describe('cordis_try', () => { ], ])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'circular-schema', @@ -531,7 +531,7 @@ describe('cordis_try', () => { it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'proto-schema', @@ -566,7 +566,7 @@ describe('cordis_try', () => { it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'nested-schema', @@ -593,7 +593,7 @@ describe('cordis_try', () => { it('rejects raw dynamic ctx.tools.register calls that bypass harness helpers', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'raw-register', @@ -618,7 +618,7 @@ describe('cordis_try', () => { it('guards the registry reached through ctx.get(\'tools\') identically', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'raw-register-get', @@ -636,13 +636,13 @@ describe('cordis_try', () => { it('passes non-register registry members through the guard with correct binding', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'schema-reader', inject: ['tools'], apply(ctx) { - console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_try')) + console.log('sees', ctx.tools.schemas().length, 'tools; mount is', typeof ctx.tools.get('cordis_mount')) }, } `, @@ -653,11 +653,11 @@ describe('cordis_try', () => { it('keeps a plugin with unsatisfied inject mounted as pending and names what it waits for', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'waiter\', inject: [\'no-such-service\'], apply(ctx) {} }', }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected pending cordis_try success') + if (result.isError) throw new Error('expected pending cordis_mount success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'waiter', @@ -665,15 +665,15 @@ describe('cordis_try', () => { provides: [], waitingFor: ['no-such-service'], }) - expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until stopped or DSH restarts).') + expect(text(result)).toBe('Temporary Plugin dyn-1 is pending (plugin "waiter"; missing services: no-such-service; available until unmounted or DSH restarts).') // Unmounting a pending mount works like any other. - const unmounted = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + const unmounted = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(unmounted.isError).toBe(false) }) it('rejects code that throws, leaving nothing mounted', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: 'throw new Error(\'boom in sandbox\')' }) + const result = await call(ctx, 'cordis_mount', { code: 'throw new Error(\'boom in sandbox\')' }) expect(result.isError).toBe(true) expect(text(result)).toContain('boom in sandbox') expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') @@ -681,30 +681,30 @@ describe('cordis_try', () => { it('passes non-Error and null throws through untouched (no SyntaxError misclassification)', async () => { const ctx = await setup() - const primitive = await call(ctx, 'cordis_try', { code: 'throw \'plain-string-throw\'' }) + const primitive = await call(ctx, 'cordis_mount', { code: 'throw \'plain-string-throw\'' }) expect(primitive.isError).toBe(true) expect(text(primitive)).toContain('plain-string-throw') - const nullish = await call(ctx, 'cordis_try', { code: 'throw null' }) + const nullish = await call(ctx, 'cordis_mount', { code: 'throw null' }) expect(nullish.isError).toBe(true) }) it('rejects code that does not return a plugin', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: 'return 42' }) + const result = await call(ctx, 'cordis_mount', { code: 'return 42' }) expect(result.isError).toBe(true) expect(text(result)).toContain('must `return` a Plugin') }) it('answers a missing return with the two valid plugin forms', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: 'const plugin = (ctx) => {}' }) + const result = await call(ctx, 'cordis_mount', { code: 'const plugin = (ctx) => {}' }) expect(result.isError).toBe(true) expect(text(result)).toContain('did you forget `return`?') }) it('disposes a plugin whose apply throws, and reports the error', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }', }) expect(result.isError).toBe(true) @@ -714,14 +714,14 @@ describe('cordis_try', () => { it('rolls back a plugin that collides with an existing tool name, keeping the original tool intact', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'usurper', inject: ['tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ - name: 'cordis_try', + name: 'cordis_mount', description: 'dup', parameters: {}, ${CONTENT_OUTPUT_CODE} @@ -733,15 +733,15 @@ describe('cordis_try', () => { }) expect(result.isError).toBe(true) expect(text(result)).toContain('already registered') - expect(text(result)).toContain('first cordis_stop') - // The original cordis_try still dispatches — the failed fiber is gone. - const retry = await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + expect(text(result)).toContain('first cordis_unmount') + // The original cordis_mount still dispatches — the failed fiber is gone. + const retry = await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) expect(retry.isError).toBe(false) }) it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` globalThis.__cordis_tool_leak = 'leaked' return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} } @@ -758,7 +758,7 @@ describe('cordis_try', () => { ['fetch(\'https://example.com\')', 'fetch is not available in the temporary Plugin sandbox', 'ctx.web'], ])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: `${invocation}\nreturn (ctx) => {}` }) + const result = await call(ctx, 'cordis_mount', { code: `${invocation}\nreturn (ctx) => {}` }) expect(result.isError).toBe(true) expect(text(result)).toContain(trapMessage) expect(text(result)).toContain(redirect) @@ -768,7 +768,7 @@ describe('cordis_try', () => { it('lets a mounted plugin schedule through the cordis timer service (inject: [\'timer\'])', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'ticker', @@ -789,7 +789,7 @@ describe('cordis_try', () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` console.warn('warned') console.error('errored') @@ -807,7 +807,7 @@ describe('cordis_try', () => { it('answers TypeScript syntax in the plain-JS sandbox with the fix', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'ts\' as const, apply(ctx) {} }', }) expect(result.isError).toBe(true) @@ -819,7 +819,7 @@ describe('cordis_try', () => { // The canonical model mistake: closing the returned object with `});` as // if it were a callback argument. The word "as" in a STRING elsewhere must // not trigger the TypeScript hint — the heuristic reads the failing line. - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});', }) expect(result.isError).toBe(true) @@ -842,7 +842,7 @@ describe('cordis_try', () => { it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { code: 'throw new SyntaxError(\'user-crafted\')' }) + const result = await call(ctx, 'cordis_mount', { code: 'throw new SyntaxError(\'user-crafted\')' }) expect(result.isError).toBe(true) expect(text(result)).toContain('failed to parse') expect(text(result)).toContain('user-crafted') @@ -850,7 +850,7 @@ describe('cordis_try', () => { it('honors the configured vmTimeoutMs for the synchronous portion', async () => { const ctx = await setup({ vmTimeoutMs: 50 }) - const result = await call(ctx, 'cordis_try', { code: 'while (true) {}' }) + const result = await call(ctx, 'cordis_mount', { code: 'while (true) {}' }) expect(result.isError).toBe(true) expect(text(result)).toMatch(/timed? ?out/i) expect(text(await call(ctx, 'cordis_inspect', { what: 'temporary' }))).toContain('No temporary Plugins are running.') @@ -861,7 +861,7 @@ describe('cordis_try', () => { // Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently // false. const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'probe-instanceof', diff --git a/packages/cordis/tool-cordis/tests/present.spec.ts b/packages/cordis/tool-cordis/tests/present.spec.ts index 26b9360672..b9c4622054 100644 --- a/packages/cordis/tool-cordis/tests/present.spec.ts +++ b/packages/cordis/tool-cordis/tests/present.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { presentInspectCall, presentTryCall, presentStopCall } from '../src/present.ts' +import { presentInspectCall, presentMountCall, presentUnmountCall } from '../src/present.ts' import { setup } from './helpers.ts' /** @@ -18,17 +18,17 @@ describe('presenters', () => { }) }) - it('cordis_try renders a generic execute card carrying the code as raw input', () => { - expect(presentTryCall({ code: 'return (ctx) => {}' })).toEqual({ + it('cordis_mount renders a generic execute card carrying the code as raw input', () => { + expect(presentMountCall({ code: 'return (ctx) => {}' })).toEqual({ card: 'generic', kind: 'execute', - title: 'Try temporary Cordis Plugin', + title: 'Mount temporary Cordis Plugin', rawInput: { code: 'return (ctx) => {}' }, }) }) - it('cordis_stop renders a generic delete card titled with the id', () => { - expect(presentStopCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Stop temporary Cordis Plugin dyn-1' }) + it('cordis_unmount renders a generic delete card titled with the id', () => { + expect(presentUnmountCall({ id: 'dyn-1' })).toEqual({ card: 'generic', kind: 'delete', title: 'Unmount temporary Cordis Plugin dyn-1' }) }) it('is wired onto the registered definitions through the defineTool soft-validation path', async () => { @@ -41,9 +41,9 @@ describe('presenters', () => { expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({ title: 'Inspect cordis runtime: api: tools', }) - expect(ctx.tools.get('cordis_try')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) - expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Stop temporary Cordis Plugin dyn-2' }) + expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) + expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount temporary Cordis Plugin dyn-2' }) // Soft validation: presenter args that fail the schema render as no card, never a throw. - expect(ctx.tools.get('cordis_stop')!.presentCall!({ id: 42 })).toBeUndefined() + expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 42 })).toBeUndefined() }) }) diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index 0def756ebd..02828e46ca 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -10,7 +10,7 @@ import { call, CONTENT_OUTPUT_CODE, setup, text } from './helpers.ts' /** Mount a plugin whose `apply` touches one framework member, and report the error text. */ async function mountTouching(ctx: Awaited>, expr: string): Promise { - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`, }) expect(result.isError).toBe(true) @@ -41,7 +41,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'root-bypass', @@ -66,7 +66,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('rejects assignment to the façade rather than silently dropping it', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }', }) expect(result.isError).toBe(true) @@ -78,7 +78,7 @@ describe('sandbox context façade — escape surface is closed', () => { // `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return // guards reject that Context before the registration lands. const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'svc-ctx-escape', @@ -108,7 +108,7 @@ describe('sandbox context façade — escape surface is closed', () => { name: 'host-async-svc', apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) }, }) - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'async-consumer', @@ -135,7 +135,7 @@ describe('sandbox context façade — escape surface is closed', () => { it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'introspector', @@ -157,7 +157,7 @@ describe('sandbox context façade — inject gate on services', () => { // mount does not declare it — reaching it would let the mount depend on a // provider cordis does not know about, so it is refused. const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'undeclared\', inject: [\'tools\'], apply(ctx) { const s = ctx.systemPrompt } }', }) expect(result.isError).toBe(true) @@ -167,7 +167,7 @@ describe('sandbox context façade — inject gate on services', () => { it('denies an undeclared live service reached through ctx.get too', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: 'return { name: \'undeclared-get\', inject: [\'tools\'], apply(ctx) { ctx.get(\'systemPrompt\') } }', }) expect(result.isError).toBe(true) @@ -176,7 +176,7 @@ describe('sandbox context façade — inject gate on services', () => { it('allows a service the mount DID declare in inject', async () => { const ctx = await setup() - const result = await call(ctx, 'cordis_try', { + const result = await call(ctx, 'cordis_mount', { code: ` return { name: 'declared', @@ -193,10 +193,10 @@ describe('sandbox context façade — inject gate on services', () => { // Without declared inject, Cordis cannot park the consumer when its provider unmounts. The // façade refuses access up front instead of leaving a zombie tool. const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }', }) - const undeclared = await call(ctx, 'cordis_try', { + const undeclared = await call(ctx, 'cordis_mount', { code: ` return { name: 'sloppy-consumer', @@ -229,7 +229,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { // function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now // returns the same name/description/parameters view as schemas(), with no execute. const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'reporter', @@ -241,7 +241,7 @@ describe('sandbox tools façade — get is a read-only schema view', () => { parameters: {}, ${CONTENT_OUTPUT_CODE} async execute() { - const view = ctx.tools.get('cordis_try') + const view = ctx.tools.get('cordis_mount') return [{ type: 'text', text: JSON.stringify({ hasExecute: 'execute' in view, hasPresentCall: 'presentCall' in view, @@ -259,13 +259,13 @@ describe('sandbox tools façade — get is a read-only schema view', () => { const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] } expect(shape.hasExecute).toBe(false) expect(shape.hasPresentCall).toBe(false) - expect(shape.name).toBe('cordis_try') + expect(shape.name).toBe('cordis_mount') expect(shape.keys).toEqual(['description', 'name', 'parameters']) }) it('ctx.tools.get returns undefined for an unknown tool', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { + await call(ctx, 'cordis_mount', { code: ` return { name: 'unknown-probe', diff --git a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts index dbd73dfa14..32846e35dd 100644 --- a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts @@ -30,8 +30,8 @@ describe('tool registration', () => { it('registers the three cordis tools with the documented schemas', async () => { const ctx = await setup() const names = ctx.tools.schemas().map(schema => schema.name) - expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_try', 'cordis_stop'])) - expect(names).not.toEqual(expect.arrayContaining(['cordis_mount', 'cordis_unmount'])) + expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + expect(names).not.toEqual(expect.arrayContaining(['cordis_try', 'cordis_stop'])) const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')! const props = (inspect.parameters as { properties: Record }).properties expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'temporary', 'api', 'events']) diff --git a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts index c16528d731..42c4c0196c 100644 --- a/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/cordis/tool-cordis/tests/unmount-hmr.spec.ts @@ -6,7 +6,7 @@ import * as tool from '../src/index.ts' import { call, dummyTool, LISTENER_CODE, REVERSE_TOOL_CODE, setup, text } from './helpers.ts' /** - * Disposal semantics: `cordis_stop` reaches quiescence before returning, + * Disposal semantics: `cordis_unmount` reaches quiescence before returning, * and disposing the tool-cordis fiber itself (the HMR path) cascades over the * whole dynamic subtree through the ordinary parent→child fiber lifecycle. */ @@ -15,20 +15,20 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('cordis_stop', () => { +describe('cordis_unmount', () => { it('disposes the mount and its registrations have stopped by the time it returns (quiescence)', async () => { const ctx = await setup() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await call(ctx, 'cordis_try', { code: LISTENER_CODE }) + await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) ctx.tools.register(dummyTool('trigger_before')) expect(log).toHaveBeenCalledTimes(1) - const result = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + const result = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(result.isError).toBe(false) - if (result.isError) throw new Error('expected cordis_stop success') + if (result.isError) throw new Error('expected cordis_unmount success') expect(result.value).toEqual({ id: 'dyn-1', pluginName: 'change-logger' }) - expect(text(result)).toBe('Temporary Plugin dyn-1 was stopped and removed.') + expect(text(result)).toBe('Temporary Plugin dyn-1 was unmounted and removed.') // Immediately after the awaited unmount, the listener must be gone — no // grace period, no eventual consistency. @@ -39,22 +39,22 @@ describe('cordis_stop', () => { it('unregisters a self-made tool on unmount', async () => { const ctx = await setup() - await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) expect(ctx.tools.get('reverse_text')).toBeDefined() - await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(ctx.tools.get('reverse_text')).toBeUndefined() }) it('rejects an unknown id, and a second unmount of the same id', async () => { const ctx = await setup() - const unknown = await call(ctx, 'cordis_stop', { id: 'dyn-99' }) + const unknown = await call(ctx, 'cordis_unmount', { id: 'dyn-99' }) expect(unknown.isError).toBe(true) expect(text(unknown)).toContain('no temporary Plugin with id "dyn-99"') - await call(ctx, 'cordis_try', { code: LISTENER_CODE }) - await call(ctx, 'cordis_stop', { id: 'dyn-1' }) - const again = await call(ctx, 'cordis_stop', { id: 'dyn-1' }) + await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) + const again = await call(ctx, 'cordis_unmount', { id: 'dyn-1' }) expect(again.isError).toBe(true) }) }) @@ -67,8 +67,8 @@ describe('HMR safety', () => { const fiber = await ctx.plugin(tool) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await call(ctx, 'cordis_try', { code: LISTENER_CODE }) - await call(ctx, 'cordis_try', { code: REVERSE_TOOL_CODE }) + await call(ctx, 'cordis_mount', { code: LISTENER_CODE }) + await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) expect(ctx.tools.get('reverse_text')).toBeDefined() await fiber.dispose() @@ -76,7 +76,7 @@ describe('HMR safety', () => { // The whole subtree is gone: the self-made tool, the cordis tools, and the // mounted listener (no log on a fresh tools/change). expect(ctx.tools.get('reverse_text')).toBeUndefined() - expect(ctx.tools.get('cordis_try')).toBeUndefined() + expect(ctx.tools.get('cordis_mount')).toBeUndefined() const calls = log.mock.calls.length ctx.tools.register(dummyTool('trigger_post_dispose')) expect(log).toHaveBeenCalledTimes(calls) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 91131be9ea..3754595f56 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_stop', 'cordis_try', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index a54821fb5a..e5c1958985 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -18,10 +18,10 @@ buffer 5| 6| "▌ " style 0-0 fg=yellow -7| "▌ ◌ Try temporary Cordis Plugin " +7| "▌ ◌ Mount temporary Cordis Plugin " style 0-0 fg=yellow style 2-2 fg=yellow bold - style 3-30 bold + style 3-32 bold 8| "▌ { " style 0-0 fg=yellow 9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " @@ -33,10 +33,10 @@ buffer 12| "▌ " style 0-0 fg=yellow 13| -14| "▌ ◌ Stop temporary Cordis Plugin dyn-1 " +14| "▌ ◌ Unmount temporary Cordis Plugin dyn-1 " style 0-0 fg=yellow style 2-2 fg=yellow bold - style 3-37 bold + style 3-40 bold 15| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim 16| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 411e7e8995..80f685e9c5 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -414,10 +414,10 @@ describe('TUI terminal-state snapshots', () => { { id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } }, { id: 'cordis-2', - name: 'cordis_try', + name: 'cordis_mount', arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" }, }, - { id: 'cordis-3', name: 'cordis_stop', arguments: { id: 'dyn-1' } }, + { id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) }) await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index b88a5e338c..7a65e14ca4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -569,7 +569,7 @@ const APP_EXAMPLES = [ title: 'Cordis Agent App Composition', label: 'examples/cordis-agent', config: 'examples/cordis-agent/cordis.yml', - summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and try or stop in-memory temporary Plugins.', + summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.', }, { id: 'acp', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 401a0ac0e8..3e08138b83 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -213,7 +213,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolCordis) }, note: - 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_try may register ADDITIONAL model-visible tools until stopped or DSH restarts; a full changed request header logs those tool-set changes.', + 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, { pkg: '@deepseek-ai/dsh-tool-fs', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 6bad2ff170..fed43ac605 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -149,10 +149,10 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: return text_chunks("WORKFLOW_CHILD_OK") if prompt == SNAPSHOT_PROMPT: - assert_advertised_tool(body, "cordis_try") + assert_advertised_tool(body, "cordis_mount") return tool_call_chunks( "advanced-mount", - "cordis_try", + "cordis_mount", {"code": SNAPSHOT_MOUNT_CODE}, ) if prompt == CODE_PROMPT: @@ -187,9 +187,9 @@ def advanced_tool_followup( """Advance the executable snapshot's deterministic parent tool chain.""" if not call_id.startswith("advanced-"): return None - if call_id == "advanced-mount" and tool_name == "cordis_try": + if call_id == "advanced-mount" and tool_name == "cordis_mount": if "Temporary Plugin dyn-1 is running" not in tool_text: - raise AssertionError(f"cordis_try returned no temporary Plugin id: {tool_text}") + raise AssertionError(f"cordis_mount returned no temporary Plugin id: {tool_text}") assert_advertised_tool(body, "run_code") assert_advertised_tool(body, "snapshot_double") return tool_call_chunks( @@ -230,17 +230,17 @@ def advanced_tool_followup( if call_id == "advanced-workflow" and tool_name == "workflow": if "WORKFLOW_CHILD_OK" not in tool_text: raise AssertionError(f"workflow returned no expected child value: {tool_text}") - assert_advertised_tool(body, "cordis_stop") + assert_advertised_tool(body, "cordis_unmount") return tool_call_chunks( "advanced-unmount", - "cordis_stop", + "cordis_unmount", {"id": "dyn-1"}, ) - if call_id == "advanced-unmount" and tool_name == "cordis_stop": - if "Temporary Plugin dyn-1 was stopped and removed." not in tool_text: - raise AssertionError(f"cordis_stop returned no stop result: {tool_text}") + if call_id == "advanced-unmount" and tool_name == "cordis_unmount": + if "Temporary Plugin dyn-1 was unmounted and removed." not in tool_text: + raise AssertionError(f"cordis_unmount returned no unmount result: {tool_text}") if "snapshot_double" in advertised_tool_names(body): - raise AssertionError("snapshot_double remained advertised after cordis_stop") + raise AssertionError("snapshot_double remained advertised after cordis_unmount") return text_chunks(SNAPSHOT_FINAL_TEXT) raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}") diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 521cf6650f..07393e7f25 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -72,8 +72,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "subagent", @@ -114,7 +114,7 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } @@ -132,7 +132,7 @@ "block": { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } @@ -180,7 +180,7 @@ { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], @@ -210,7 +210,7 @@ "turn": 1, "step": 1, "callId": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } }, @@ -225,7 +225,7 @@ "content": [ { "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)." + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." } ], "isError": false @@ -268,8 +268,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "snapshot_double", @@ -836,7 +836,7 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "argumentsDelta": "{\"id\": \"dyn-1\"}" } } @@ -854,7 +854,7 @@ "block": { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } } @@ -902,7 +902,7 @@ { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } ], @@ -932,7 +932,7 @@ "turn": 1, "step": 5, "callId": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } }, @@ -947,7 +947,7 @@ "content": [ { "type": "text", - "text": "Temporary Plugin dyn-1 was stopped and removed." + "text": "Temporary Plugin dyn-1 was unmounted and removed." } ], "isError": false @@ -990,8 +990,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "subagent", @@ -1233,8 +1233,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "subagent", @@ -1287,7 +1287,7 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } @@ -1311,7 +1311,7 @@ "block": { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } @@ -1377,7 +1377,7 @@ { "type": "tool-call", "id": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } ], @@ -1413,7 +1413,7 @@ "turn": 1, "step": 1, "callId": "advanced-mount", - "name": "cordis_try", + "name": "cordis_mount", "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" } } @@ -1434,7 +1434,7 @@ "content": [ { "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)." + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." } ], "isError": false @@ -1495,8 +1495,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "snapshot_double", @@ -2055,8 +2055,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "snapshot_double", @@ -2595,8 +2595,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "snapshot_double", @@ -2899,7 +2899,7 @@ "type": "tool-call-delta", "index": 0, "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "argumentsDelta": "{\"id\": \"dyn-1\"}" } } @@ -2923,7 +2923,7 @@ "block": { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } } @@ -2989,7 +2989,7 @@ { "type": "tool-call", "id": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } ], @@ -3025,7 +3025,7 @@ "turn": 1, "step": 5, "callId": "advanced-unmount", - "name": "cordis_stop", + "name": "cordis_unmount", "arguments": "{\"id\": \"dyn-1\"}" } } @@ -3046,7 +3046,7 @@ "content": [ { "type": "text", - "text": "Temporary Plugin dyn-1 was stopped and removed." + "text": "Temporary Plugin dyn-1 was unmounted and removed." } ], "isError": false @@ -3107,8 +3107,8 @@ "tools": [ "bash", "cordis_inspect", - "cordis_stop", - "cordis_try", + "cordis_mount", + "cordis_unmount", "run_code", "skill", "subagent", diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 820fcb50c2..05998f8980 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index ab58f50eed..778c200078 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 3549b90945..1078fe4985 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -3,18 +3,18 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_try","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_try","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until stopped or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} @@ -48,16 +48,16 @@ {"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} {"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_stop","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_stop","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was stopped and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_stop","cordis_try","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} From cc54423b290b0f41f7f49ad77f72643d908f2f71 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:39:19 +0800 Subject: [PATCH 15/16] fix(ci): refresh Cordis service catalog --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc60785d0c..2214cd83dc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1950,7 +1950,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:207`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:188`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` From 2973dbae590e3450d3394a2ce35b5d7cd2eafc5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:50:38 +0800 Subject: [PATCH 16/16] fix(ci): read durable skill catalog in TUI smoke --- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 1f4ea70d6e..cdfd7d704d 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -71,30 +71,35 @@ async function seedResumeSession(cwd: string): Promise { ].join('\n')) } -/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ -interface LoggedRequestHeader { +/** Model-visible startup context from the first request in the workspace's persisted session log. */ +interface LoggedRequestContext { /** The system prompt string the launcher sends. */ system: string - /** The baked session-prefix messages (skill catalog, workspace context) serialized to text. */ - prefix: string + /** The durable skill-catalog message serialized to text. */ + skillCatalog: string } -async function readLoggedRequestHeader(cwd: string): Promise { +async function readLoggedRequestContext(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') const entries = await readdir(sessionsDir, { recursive: true }) // A single keyless run writes one session log; the source section is global, so any log carries it. const logRelPath = entries.find(name => name.endsWith('.jsonl')) if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + let skillCatalog = '' for (const line of lines) { - const event = JSON.parse(line) as { - type: string - data: { header?: { system?: string; messagePrefix?: unknown } } + const event = JSON.parse(line) as SessionEvent + if ( + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill' + ) { + skillCatalog = JSON.stringify(event.data.content) } if (event.type === 'request/header') { return { - system: event.data.header?.system ?? '', - prefix: JSON.stringify(event.data.header?.messagePrefix ?? []), + system: event.data.header.system ?? '', + skillCatalog, } } } @@ -366,9 +371,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // 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. // The source-path line is a system-prompt section; the bundled skills reach the - // model through the session-prefix catalog, so each assertion targets its own field. + // model through a durable user message, so each assertion targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let header: LoggedRequestHeader = { system: '', prefix: '' } + let context: LoggedRequestContext = { system: '', skillCatalog: '' } await smoke({ label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', @@ -380,11 +385,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], - inspect: async (cwd) => { header = await readLoggedRequestHeader(cwd) }, + inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, }) - expect(header.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) - expect(header.prefix).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") - expect(header.prefix).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') - expect(header.prefix).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') + expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") + expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') + expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) })