From b58e33268afa9771e96bca1a565555d519d97743 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 10:18:16 +0800 Subject: [PATCH 01/41] feat(llm): add per-provider retry policies --- ...2026-06-21-bounded-llm-request-recovery.md | 34 +- ...26-07-24-provider-retry-policies.i18n.yaml | 6 + .../2026-07-24-provider-retry-policies.md | 65 +++ .../2026-07-24-provider-retry-policies.zh.md | 65 +++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 46 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 11 +- docs/core-data-structures/llm-streaming.md | 12 +- docs/event-producer-consumer.md | 2 +- docs/persistence-catalog.md | 12 +- .../tests/compact-loop-repro.spec.ts | 20 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 + packages/core/agent-loop/README.md | 2 +- packages/examples/acp-demo/README.md | 1 - packages/examples/acp-demo/src/index.ts | 3 - packages/examples/agent-spine-demo/README.md | 8 +- .../examples/agent-spine-demo/package.json | 2 +- .../examples/agent-spine-demo/src/index.ts | 16 +- .../agent-spine-demo/tests/agent-core.spec.ts | 34 +- packages/examples/cli-demo/README.md | 1 - packages/examples/cli-demo/src/index.ts | 3 - packages/examples/cli-demo/tests/cli.spec.ts | 19 +- packages/llm/README.md | 4 +- packages/llm/llm-deepseek/README.md | 10 +- packages/llm/llm-deepseek/src/adapter.ts | 12 +- packages/llm/llm-deepseek/src/index.ts | 7 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 32 ++ packages/llm/llm-pi-ai/README.md | 9 +- packages/llm/llm-pi-ai/src/adapter.ts | 13 +- packages/llm/llm-pi-ai/src/config.ts | 13 +- packages/llm/llm-pi-ai/src/index.ts | 5 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 38 +- packages/llm/llm-retry/README.md | 39 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm-retry/src/history.ts | 38 ++ packages/llm/llm-retry/src/index.ts | 230 +++++----- packages/llm/llm-retry/src/invariant.ts | 62 ++- .../llm/llm-retry/tests/invariant.spec.ts | 179 +++++++- .../tests/loader-composition.spec.ts | 22 +- .../llm/llm-retry/tests/persistence.spec.ts | 7 +- packages/llm/llm-retry/tests/retry.spec.ts | 405 ++++++++++++++++-- packages/llm/llm-retry/tsdown.config.ts | 25 ++ packages/llm/llm/README.md | 7 +- packages/llm/llm/package.json | 5 + packages/llm/llm/src/index.ts | 47 +- packages/llm/llm/src/retry-policy.ts | 184 ++++++++ packages/llm/llm/tests/retry-policy.spec.ts | 84 ++++ packages/llm/llm/tests/service.spec.ts | 22 + packages/llm/llm/tsconfig.json | 3 + packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/stream-update.spec.ts | 17 + packages/ui/tui/src/index.ts | 3 +- .../snapshots/retry-cancelled.expected.txt | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 5 +- packages/ui/tui/tests/tui.spec.ts | 6 + pnpm-lock.yaml | 7 + scripts/gen-cordis-catalog.ts | 1 + 60 files changed, 1606 insertions(+), 334 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md create mode 100644 packages/llm/llm-retry/src/history.ts create mode 100644 packages/llm/llm-retry/tsdown.config.ts create mode 100644 packages/llm/llm/src/retry-policy.ts create mode 100644 packages/llm/llm/tests/retry-policy.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 28de5eb97c..1e0ef18f9c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -2,6 +2,8 @@ Status: implemented +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. + ## Problem `dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. @@ -14,7 +16,7 @@ The prior boundary left three narrower gaps. - Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. - A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. -The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. +The default policy provides bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. ## Decision @@ -48,31 +50,19 @@ The initial shared transient-code set is intentionally small: the adapters' exis `@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. -The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. +The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. Normal `dsh-llm-retry` policy counts retry records scheduled by the same exact-provider policy, while `dsh-compact-basic` counts prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently. -The plugin resolves and validates this deployment configuration at load: - -```ts ignore-check -interface Config { - maxTransientRetries?: number - initialDelayMs?: number - maxDelayMs?: number - jitterRatio?: number - retryableCodes?: string[] -} -``` - -The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. +The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) owns the current configuration shape. Provider adapters register their nested `retryPolicy`; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. ### Make one layer own visible attempts @@ -99,7 +89,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider. - Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message. - Repairing malformed tool arguments, refusals, content filters, or other semantic model output. -- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets. +- Circuit breakers, shared provider health, or cross-agent retry budgets. - Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer. ## Alternatives considered @@ -108,7 +98,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - **Add response start, interrupted, discarded, failed, and committed events to `dsh-llm`** — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry. - **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility. - **Put `retryable` or `failover` on `LlmFailure`** — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch. -- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe. +- **Retry forever while the caller remains active** — the [per-provider policy](../feature/2026-07-24-provider-retry-policies.md) supersedes this rejection for explicit `always` entries while retaining bounded normal mode as the default. - **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. - **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. @@ -119,7 +109,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. - Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. - `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. -- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. +- Each provider adapter validates its nested retry policy at Loader startup, and `ctx.llm` captures it with the route; normal mode delegates ineligible paths and makes at most `maxRetries + 1` provider requests when no other policy applies. - HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. @@ -130,12 +120,12 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Consequences -- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Every retry attempt is visible as a closed step plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success. - Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. - Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. - Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift. -- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it. +- Multiple normal recovery plugins add their finite budgets. Always mode delegates first and then supplies an unbounded fallback; overlapping classifiers remain registration-order policy and must be documented and tested by the plugins that introduce them. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml new file mode 100644 index 0000000000..ee221716ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-provider-retry-policies.md: 0ff304715aa5e1bfaf296e3f53e1da28d8b4042d +2026-07-24-provider-retry-policies.zh.md: 3b1d8f12a5fb37424e8b964f7bbb2be278263056 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md new file mode 100644 index 0000000000..0ff304715a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -0,0 +1,65 @@ +# Agent Note: Per-provider request retry policies + +Status: implemented + +English | [中文](2026-07-24-provider-retry-policies.zh.md) + +## Problem + +One process may route model requests to providers with different reliability and cost constraints. A single transient classifier and finite retry budget cannot express a deployment that wants bounded recovery for most providers but requires one provider to keep retrying every model-request failure until the request succeeds or the caller cancels it. + +Provider policy must follow the request that actually failed, including a route selected by `agent/request`, rather than the agent's initial options. Unbounded policy also cannot store JavaScript `Infinity` in the durable session event, and neither provider error text nor discarded partial output may enter the next model request. + +## Decision + +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. `@deepseek-ai/dsh-llm-retry` reads the registered policy for the provider whose step failed. A provider without `retryPolicy` uses the normal defaults. + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +The listener selects the policy from the durable `request/header` in force when the failed step closed, excluding later recovery mutations. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. + +Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. Success, turn cancellation, and plugin disposal are the only termination paths. + +Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff. + +Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface. + +## Alternatives considered + +**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. + +**A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. + +**A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. + +**Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record. + +**Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request. + +## Verification + +Adapter tests validate nested policies at provider load and prove registration captures configured and default policies. Unit and real-Loader composition tests select policies from the failed request's provider, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation interrupts stalled downstream recovery, and prove cancellation and disposal stop active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; ACP and TUI tests render finite and infinite limits. + +## Consequences + +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and exact-provider selection keeps one provider's exceptional policy from changing another provider's recovery behavior. + +This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md new file mode 100644 index 0000000000..3b1d8f12a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 逐提供方请求重试策略 + +Status: implemented + +[English](2026-07-24-provider-retry-policies.md) | 中文 + +## 问题 + +同一进程可能把模型请求路由到可靠性和成本约束各不相同的提供方。单一的瞬态错误分类器与有限重试预算无法表达这种部署需求:大多数提供方只需有界恢复,但其中一个提供方必须持续重试每次模型请求失败,直到请求成功或调用方取消。 + +提供方策略必须跟随实际失败的请求,包括 `agent/request` 选择的路由,而不能跟随 agent(智能体)的初始选项。无界策略也不能把 JavaScript `Infinity` 存入持久会话事件;提供方错误文本与丢弃的部分输出都不得进入下一次模型请求。 + +## 决策 + +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。`@deepseek-ai/dsh-llm-retry` 读取失败步骤对应提供方的已注册策略。未配置 `retryPolicy` 的提供方使用 normal 默认值。 + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +监听器根据失败步骤关闭时生效的持久 `request/header` 选择策略,后续恢复产生的改动不参与选择。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 + +always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。成功、轮次取消和插件 dispose(资源释放)是仅有的终止路径。 + +两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 + +每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。 + +## 曾考虑的替代方案 + +**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 + +**在 `dsh-llm-retry` 上维护单独的精确提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 + +**设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的契约,并把任意选取的运维上限序列化成看似有意义的数值。 + +**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭 step 的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 + +**把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。 + +## 验证 + +适配器测试会在提供方加载时校验嵌套策略,并证明注册流程会捕获已配置策略和默认策略。单元测试与真实 Loader 组合测试根据失败请求的提供方选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消会中断停滞的下游恢复,并证明取消与 dispose 会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;ACP 与 TUI 测试会分别渲染有限和无限上限。 + +## 后果 + +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;精确提供方选择也能避免某个提供方的例外策略改变其他提供方的恢复行为。 + +本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭 step 恢复、单次可见适配器尝试、结构化失败与持久状态设计。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 466a96dd30..afa1d20efd 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 -architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 -architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b +architecture.md: 3347e529f7e2c7b4c376d01132851d883c90182e +architecture.zh.md: f3d3bf8b19bace2124b89c3b6ee7e4587445b9fe diff --git a/docs/architecture.md b/docs/architecture.md index 5a0ff63413..3347e529f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ Each step assembles ordered prompt sections, tool schemas, and variables; unknow Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. -Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Adapters register nested `retryPolicy`; normal bounds transient failures, while always retries until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). ### Failure Boundaries diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e1fb143982..f3d3bf8b19 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -119,7 +119,7 @@ forever: 工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。 -裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。适配器会注册嵌套的 `retryPolicy`;normal 限制瞬态错误重试次数,always 则持续重试,直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 ### 失败边界 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2036a34307 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -76,8 +76,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Bounded transient model-request retry policy forwarded through agent-core. */ - llmRetry?: NonNullable } ``` @@ -127,8 +125,9 @@ Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, `llmRetry` to the bounded request-recovery policy, - * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Provider adapters own their `retryPolicy`; this + * bundle always mounts its executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -164,8 +163,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Bounded transient model-request retry policy. */ - llmRetry?: llmRetry.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -189,9 +186,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -265,8 +262,6 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable - /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ - llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -538,6 +533,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** One optional model entry advertised by the hand-written adapter. */ @@ -553,7 +550,9 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) +Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) + +Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -590,12 +589,14 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -645,25 +646,14 @@ Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` +Requires: `agents` · `llm` ```ts config-catalog -/** Deployment-owned limits and classification for transient request recovery. */ -export interface Config { - /** Maximum transient retries after the first request (default 2). */ - maxTransientRetries?: number - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:43`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..f301ff6528 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -547,7 +547,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:55`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5858799e7c..99c89b7ff3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -653,6 +653,13 @@ registerAdapter(providers: string[], adapter: LlmAdapter): () => void */ listProviders(): LlmProviderInfo[] +/** + * Resolve the retry policy captured when one provider route was registered. + * @param provider - registered provider route to inspect. + * @returns the provider-owned policy, with normal defaults already resolved. + */ +providerRetryPolicy(provider: string): ResolvedRetryPolicy + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -686,9 +693,9 @@ async resolveModelContext( provider: string, model: string, ): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:171`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..820454495a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -66,6 +66,10 @@ Every adapter MUST obey these, and every consumer may rely on them: This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +## `ResolvedRetryPolicy` + +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the captured value and supplies normal defaults when the adapter omits one. The [generated config catalog](../config-catalog.md) owns the optional input shapes. + ## `AppIdentity` — app attribution The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). @@ -154,7 +158,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -170,6 +174,12 @@ declare abstract class LlmAdapter { * @returns detached display metadata whose id must equal `provider`. */ providerInfo(provider: string): LlmProviderInfo; + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..9db87fd0a3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:55`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8c1d815bc0..0dce9a770c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -299,14 +299,24 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `llm/retry` — log-only ```ts persistence-catalog -/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ +/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ 'llm/retry': { turn: number step: number + provider: string + mode: 'normal' retry: number maxRetries: number delayMs: number failure: LlmFailure +} | { + turn: number + step: number + provider: string + mode: 'always' + retry: number + delayMs: number + failure: LlmFailure } ``` diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f9f61e4096..86b0fdc6fb 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -68,6 +68,11 @@ class StepwiseToolAdapter extends LlmAdapter { class OverflowRecoveryAdapter extends LlmAdapter { readonly conversationRequests: GenerateOptions[] = [] readonly summaryRequests: GenerateOptions[] = [] + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'compaction test provider retryPolicy') constructor( private readonly delivery: 'thrown' | 'in-band', @@ -80,6 +85,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { return Promise.resolve({ contextWindow: 128 }) } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override async * stream(options: GenerateOptions): AsyncIterable { // The cache-reusing summarizer replays the conversation prefix and marks // its call only by the compaction instruction in the trailing user message. @@ -340,12 +349,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(LlmRetry, { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }) + await ctx.plugin(LlmRetry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6939ec9927..d25338d99b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -336,6 +336,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listProviders(): LlmProviderInfo[]', jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', }, + { + signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', + jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */', + }, { signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', @@ -1693,6 +1697,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'ResolvedAlwaysRetryPolicy', + declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', + }, + { + name: 'ResolvedNormalRetryPolicy', + declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', + }, + { + name: 'ResolvedRetryBackoff', + declaration: 'export interface ResolvedRetryBackoff {\n readonly initialDelayMs: number;\n readonly maxDelayMs: number;\n readonly jitterRatio: number;\n}', + }, + { + name: 'ResolvedRetryPolicy', + declaration: 'export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy;', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79fbbd7824..3314dfd7c1 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -69,7 +69,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) - Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` -- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events +- Model-request recovery: `dsh-llm-retry` on `agent/request-error`, with exact-provider normal or unbounded policies and non-surface `llm/retry` status events - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: `session/event` + `session/flush` diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 93d89d6557..ef46d95f98 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -42,7 +42,6 @@ The app owns this cluster through one ordered Cordis effect. Teardown drains the | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | -| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory and the parent of the derived `session-query.db` index | | `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 1a05a14e3e..3772e2ce1c 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -76,8 +76,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Bounded transient model-request retry policy forwarded through agent-core. */ - llmRetry?: NonNullable } // Each front door owns a complete, directly readable config schema; extracting @@ -104,7 +102,6 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 57d5922535..af8b0af0f2 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -21,7 +21,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-goal optional persisted same-session goal domain @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver -@deepseek-ai/dsh-llm-retry bounded transient request retry policy +@deepseek-ai/dsh-llm-retry provider-routed request retry policy @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @@ -53,11 +53,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -65,7 +65,7 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index bf69e27787..11a8a890c9 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine with fallback session titles, bounded retry, and optional persisted goals", + "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index c43ee2ab8d..d2a65844ea 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -74,8 +74,9 @@ export interface GoalConfig { * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, `llmRetry` to the bounded request-recovery policy, - * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Provider adapters own their `retryPolicy`; this + * bundle always mounts its executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -111,8 +112,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Bounded transient model-request retry policy. */ - llmRetry?: llmRetry.Config } /** The skill config schema exported for app packages that forward `skills`. */ @@ -139,9 +138,6 @@ export const GoalConfigSchema: z = z.object({ tool: toolGoal.Config, }) -/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ -export const LlmRetryConfigSchema: z = llmRetry.Config - /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -156,8 +152,7 @@ export const Config = z.intersect([ toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - llmRetry: LlmRetryConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -179,7 +174,6 @@ export function pickSpineConfig(config: Omit): Omit { this.requests += 1 @@ -219,15 +237,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('loads and configures bounded request recovery for every bundled front door', async () => { const adapter = new TransientOnceAdapter() - const ctx = await mount({ - workspaceContext: false, - llmRetry: { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }, - }) + const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ sessionId: SessionId('bundled-retry-session'), @@ -242,7 +252,7 @@ describe('dsh-agent-spine-demo bundle', () => { const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') expect(retryEvents).toHaveLength(1) expect(retryEvents[0]?.data.retry).toBe(1) - expect(retryEvents[0]?.data.maxRetries).toBe(1) + expect(retryEvents[0]?.data).toMatchObject({ provider: 'mock', mode: 'normal', maxRetries: 1 }) expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover') expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') await handle.dispose() @@ -523,7 +533,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: { enableRunInBackground: false }, toolTasks: false as const, invariants: { enabled: false }, - llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -537,7 +546,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, - llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 8a931cc147..a0b049ee68 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -19,7 +19,6 @@ The package mounts no console logger, interactive UI, user-interaction service, | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | -| `llmRetry` | owner defaults | bounded transient model-request retry policy | | `persistenceRoot` | `./.sessions` | JSONL session root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index f7d543c3fe..bfec7c9a4f 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -50,8 +50,6 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable - /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ - llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -74,7 +72,6 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 65eafff1cc..f467033128 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -3,7 +3,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + resolveRetryPolicy, + type GenerateOptions, + type ResolvedRetryPolicy, + type StreamChunk, + type TokenUsage, +} from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { afterEach, describe, expect, it } from 'vitest' import * as cliDemo from '../src/index.ts' @@ -20,11 +28,19 @@ type ScriptEntry = readonly StreamChunk[] | 'hang' class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] private cursor = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'cli test provider retryPolicy') constructor(private readonly script: readonly ScriptEntry[]) { super() } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script[this.cursor++] @@ -107,7 +123,6 @@ async function harness(script: readonly ScriptEntry[]): Promise { persistenceRoot: root, skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, workspaceContext: false, - llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }) await new Promise(resolve => setTimeout(resolve, 80)) ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) diff --git a/packages/llm/README.md b/packages/llm/README.md index 0c937c17dc..6585de4b9a 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -6,8 +6,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | -| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | +| `llm-retry/` | Exact-provider normal or unbounded request retry policy | (listens to `agent/request-error`) | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. +The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter supplies retry policy and optionally resolves exact provider/model context capacity; the retry executor and token meter remain provider-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a9a1bfdbc..177312c1ef 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -17,6 +17,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; high | max — omitted ⇒ not sent streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + retryPolicy: # optional; omission uses bounded normal defaults + mode: always # normal | always + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash @@ -26,7 +32,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -34,7 +40,7 @@ The plugin registers the single provider route `deepseek`. A request selects it `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## App attribution diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 64faa3b725..4a270fecd1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,12 +5,14 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, + ResolvedRetryPolicy, + RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -46,6 +48,8 @@ export interface DeepSeekAdapterOptions { models?: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -95,6 +99,7 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin */ export class DeepSeekAdapter extends LlmAdapter { private readonly streamIdleTimeoutMs: number + private readonly retryPolicy: ResolvedRetryPolicy constructor(private readonly options: DeepSeekAdapterOptions) { super() @@ -110,12 +115,17 @@ export class DeepSeekAdapter extends LlmAdapter { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { return { id: provider, name: 'DeepSeek' } } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override listModels(provider: string): Promise { return Promise.resolve((this.options.models ?? []).map(model => ({ provider, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 66828fc954..8172dd5420 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -7,7 +7,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type {} from '@deepseek-ai/dsh-llm' +import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' @@ -46,6 +47,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } const catalogModel: z = z.object({ @@ -63,6 +66,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -111,5 +115,6 @@ export function apply(ctx: Context, config: Config): void { : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, + ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, })) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 145017ea3d..6203c7db67 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -522,6 +522,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + it('owns the deepseek provider and advertises the default models', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -731,4 +751,16 @@ describe('plugin registration and config', () => { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/streamIdleTimeoutMs/) }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..31d890c0f1 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -17,6 +17,13 @@ Configure credentials and deployment-specific transport settings per provider. O apiKey: !!js process.env.OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + retryPolicy: + mode: normal + maxRetries: 3 + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 @@ -30,7 +37,7 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..59f8fce354 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,7 +13,7 @@ import type { SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' @@ -30,7 +30,10 @@ export interface PiAiAdapterOptions { * Resolve a catalog model dynamically and apply only the configured endpoint * override, preserving the catalog's API/capability/compatibility metadata. */ -function resolveModel(profile: PiAiProviderProfile, modelId: string): Model { +function resolveModel( + profile: Omit, + modelId: string, +): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') @@ -39,7 +42,7 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ -function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { +function profileOptions(profile: Omit): SimpleStreamOptions { return { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }, @@ -75,6 +78,10 @@ export class PiAiAdapter extends LlmAdapter { this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.profiles.get(provider)?.retryPolicy + } + override listModels(provider: string): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 199463aaf6..5c6916c38e 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -8,6 +8,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 @@ -36,12 +38,16 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile { +export interface ResolvedPiAiProviderProfile extends Omit { /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number + /** Immutable retry policy captured with this provider route. */ + retryPolicy: ResolvedRetryPolicy } /** Plugin configuration: the non-empty provider profiles this instance owns. */ @@ -69,6 +75,7 @@ const profile = z.object({ timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Runtime schema for {@link Config}. */ @@ -115,6 +122,10 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): Resol return { ...source, streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy( + source.retryPolicy, + `llm-pi-ai: provider "${source.provider}" retryPolicy`, + ), ...source.headers === undefined ? {} : { headers: { ...source.headers } }, ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ab08f21b81..da104cb22d 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -10,6 +10,9 @@ * providers: * - provider: openai * apiKey: !!js process.env.OPENAI_API_KEY + * retryPolicy: + * mode: normal + * maxRetries: 2 * - provider: anthropic * apiKey: !!js process.env.ANTHROPIC_API_KEY * - provider: openrouter @@ -36,6 +39,6 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles }) + const adapter = new PiAiAdapter({ profiles: config.providers }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..7b059e2f78 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -303,12 +303,31 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai' }, { provider: 'anthropic' }], + providers: [ + { + provider: 'openai', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }, + { provider: 'anthropic' }, + ], }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, { id: 'anthropic', name: 'anthropic' }, ]) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) }) @@ -373,6 +392,23 @@ describe('provider profile lifecycle', () => { } }) + it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { + expect(() => resolveProfiles([{ + provider: 'openai', + retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, + }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { + providers: [{ + provider: 'openai', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + }], + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 699e7e3dad..711666b8a0 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -1,41 +1,50 @@ # `@deepseek-ai/dsh-llm-retry` -Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. +Function plugin that applies exact-provider retry policy on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm`. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it. -Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, failure, and scheduled delay. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort the wait; disposal drains active backoffs, and a callback captured before disposal fails closed. + +The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, has a unique step record and correct provider-policy retry number, and carries a valid mode-specific budget and bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml -- name: '@deepseek-ai/dsh-llm-retry' +- name: '@deepseek-ai/dsh-llm-deepseek' config: - maxTransientRetries: 2 - initialDelayMs: 500 - maxDelayMs: 10000 - jitterRatio: 0.1 - retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + apiKey: !!js process.env.DEEPSEEK_API_KEY + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 + +- name: '@deepseek-ai/dsh-llm-retry' ``` +The executor has no policy config. Multi-provider adapters such as `dsh-llm-pi-ai` place `retryPolicy` inside each provider profile, avoiding a second provider-name list. + ## Model Experience -### Transient request recovery +### Model-request recovery #### What the model sees -No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages. +No retry event, delay, provider error, or failed partial output is model-visible. The next numbered step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface. #### Token effect -Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens. +Each retry is a new provider request and may repeat input-token billing. Normal mode has a finite budget; always mode can consume unbounded requests until success or cancellation. `llm/retry` itself contributes no tokens. #### KV Cache effect -The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity. +The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface retry event does not change cache identity. ## Known Limitations and Deferred Work - **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. -- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior. +- **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls. +- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that never settles also prevents the fallback from running. - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6d6c27636c..9ee3b5f896 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-retry", - "description": "Bounded transient LLM request retry policy for the DeepSeek Harness", + "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts new file mode 100644 index 0000000000..2b86a77e03 --- /dev/null +++ b/packages/llm/llm-retry/src/history.ts @@ -0,0 +1,38 @@ +/** Durable request-route lookup for one closed model step. @module @deepseek-ai/dsh-llm-retry/history */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Find the provider in force when one step closed, excluding later recovery mutations. + * A preceding retry is also a route marker because every provider change + * requires a newer full request-header snapshot. + * @param events - session events containing the closed step. + * @param turn - turn that owns the failed step. + * @param step - failed step whose provider is required. + * @returns the provider from the request header in force at that step boundary. + */ +export function providerForClosedStep( + events: readonly SessionEvent[], + turn: number, + step: number, +): string | undefined { + const stepEndIndex = events.findLastIndex(event => + event.type === 'step/end' + && event.data.turn === turn + && event.data.step === step, + ) + if (stepEndIndex < 0) return undefined + for (let index = stepEndIndex; index >= 0; index -= 1) { + // The loop bounds prove this indexed read exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! + if (event.type === 'request/header') return event.data.header.config.provider + if (event.type === 'llm/retry' + && event.data.turn === turn + && event.data.step < step) { + return event.data.provider + } + if (event.type === 'turn/start' || event.type === 'turn/end') return undefined + } + return undefined +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 4edf22d6f2..04f1b0996a 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -1,5 +1,5 @@ /** - * Bounded transient model-request retry policy on the agent loop's closed-step + * Provider-routed model-request retry policy on the agent loop's closed-step * recovery seam. Each scheduled retry is durable before its cancellable wait. * * @module @deepseek-ai/dsh-llm-retry @@ -8,103 +8,50 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' -import type { LlmFailure } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-session' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { providerForClosedStep } from './history.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ + /** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ 'llm/retry': { turn: number step: number + provider: string + mode: 'normal' retry: number maxRetries: number delayMs: number failure: LlmFailure + } | { + turn: number + step: number + provider: string + mode: 'always' + retry: number + delayMs: number + failure: LlmFailure } } } export const name = 'llm-retry' -export const inject = ['agents'] +export const inject = ['agents', 'llm'] -const DEFAULT_MAX_TRANSIENT_RETRIES = 2 -const DEFAULT_INITIAL_DELAY_MS = 500 -const DEFAULT_MAX_DELAY_MS = 10_000 -const DEFAULT_JITTER_RATIO = 0.1 -const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) - -/** Deployment-owned limits and classification for transient request recovery. */ -export interface Config { - /** Maximum transient retries after the first request (default 2). */ - maxTransientRetries?: number - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> /** Runtime schema for {@link Config}. */ -export const Config: z = z.object({ - maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES), - initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), - maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), - jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), - retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), -}) +export const Config: z = z.object({}) -interface ResolvedConfig { - readonly maxTransientRetries: number - readonly initialDelayMs: number - readonly maxDelayMs: number - readonly jitterRatio: number - readonly retryableCodes: ReadonlySet -} - -function resolveConfig(config: Config): ResolvedConfig { - const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES - const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS - const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS - const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO - const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] - - if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) { - throw new Error('llm-retry: maxTransientRetries must be a non-negative integer') +function validateConfig(config: Config): void { + const [key] = Object.keys(config) + if (key === undefined) return + if (key === 'retryPolicy') { + throw new Error('llm-retry: retryPolicy belongs under each provider configuration') } - if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (initialDelayMs > maxDelayMs) { - throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs') - } - if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { - throw new Error('llm-retry: jitterRatio must be between 0 and 1') - } - if (codes.length === 0) { - throw new Error('llm-retry: retryableCodes must not be empty') - } - if (codes.some(code => code.length === 0)) { - throw new Error('llm-retry: retryableCodes must contain only non-empty strings') - } - if (new Set(codes).size !== codes.length) { - throw new Error('llm-retry: retryableCodes must not contain duplicates') - } - - return Object.freeze({ - maxTransientRetries, - initialDelayMs, - maxDelayMs, - jitterRatio, - retryableCodes: new Set(codes), - }) + throw new Error(`llm-retry: unknown key "${key}"`) } /** Non-serializable seams used to make timing policy deterministic in tests. */ @@ -113,7 +60,38 @@ export interface RetryInternals { random?: () => number } -function localDelay(config: ResolvedConfig, retry: number, random: () => number): number { +type DownstreamOutcome = + | { readonly type: 'decision'; readonly decision: RequestErrorDecision } + | { readonly type: 'error'; readonly error: unknown } + | { readonly type: 'aborted' } + +function downstreamUntilAbort( + next: () => Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve({ type: 'aborted' }) + return new Promise((resolve) => { + const finish = (outcome: DownstreamOutcome): void => { + signal.removeEventListener('abort', onAbort) + resolve(outcome) + } + const onAbort = (): void => { finish({ type: 'aborted' }) } + signal.addEventListener('abort', onAbort, { once: true }) + let downstream: Promise + try { + downstream = next() + } catch (error: unknown) { + finish({ type: 'error', error }) + return + } + void downstream.then( + (decision) => { finish({ type: 'decision', decision }) }, + (error: unknown) => { finish({ type: 'error', error }) }, + ) + }) +} + +function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number { const exponent = Math.min(retry - 1, 1024) const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs) const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random() @@ -136,13 +114,13 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise>() @@ -152,25 +130,40 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna turn: number, step: number, failure: LlmFailure, + provider: string, + policy: ResolvedRetryPolicy, retry: number, delayMs: number, signal: AbortSignal, ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) if (fusedSignal.aborted) return { action: 'fail' } - agent.session.append('llm/retry', { - turn, - step, - retry, - maxRetries: resolved.maxTransientRetries, - delayMs, - failure, - }) + const eventData = policy.mode === 'normal' + ? { + turn, + step, + provider, + mode: policy.mode, + retry, + maxRetries: policy.maxRetries, + delayMs, + failure, + } + : { + turn, + step, + provider, + mode: policy.mode, + retry, + delayMs, + failure, + } + agent.session.append('llm/retry', eventData) if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' } return { action: 'retry' } } - const disposeListener = ctx.on('agent/request-error', ( + const disposeListener = ctx.on('agent/request-error', async ( agent: Agent, turn: number, step: number, @@ -184,22 +177,61 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) - if (!resolved.retryableCodes.has(failure.code)) return next() - const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length - if (priorTransientFailures >= resolved.maxTransientRetries) return next() + // Bind policy to the header in force when this step closed. Downstream + // recovery may append later state before an always fallback runs. + const provider = providerForClosedStep(agent.session.events, turn, step) + /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ + if (provider === undefined) { + throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) + } + const policy = ctx.llm.providerRetryPolicy(provider) - const retry = priorTransientFailures + 1 + if (policy.mode === 'always') { + const downstream = await downstreamUntilAbort( + next, + AbortSignal.any([signal, lifetime.signal]), + ) + if (downstream.type === 'aborted') return { action: 'fail' } + if (downstream.type === 'error') { + ctx.logger.warn( + `llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, + downstream.error, + ) + } + if (downstream.type === 'decision' && downstream.decision.action === 'retry') { + return downstream.decision + } + } else if (!policy.retryableCodes.includes(failure.code)) { + return next() + } + + const firstPriorStep = step - priorFailures.length + const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => + event.type === 'llm/retry' + && event.data.turn === turn + && event.data.step >= firstPriorStep + && event.data.step < step + && event.data.provider === provider + && event.data.mode === policy.mode, + ) + const previousRetry = priorPolicyRetry?.data.retry ?? 0 + if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() + const retry = previousRetry + 1 let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) && failure.providerRetryAfterMs > 0) { - if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next() - delayMs = failure.providerRetryAfterMs + if (failure.providerRetryAfterMs > policy.maxDelayMs) { + if (policy.mode === 'normal') return next() + delayMs = localDelay(policy, retry, random) + } else { + delayMs = failure.providerRetryAfterMs + } } else { - delayMs = localDelay(resolved, retry, random) + delayMs = localDelay(policy, retry, random) } - const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal) + const tracked = backoff(agent, turn, step, failure, provider, policy, retry, delayMs, signal) .finally(() => active.delete(tracked)) active.add(tracked) return tracked diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 784f459606..0cabbd6365 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -4,6 +4,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { providerForClosedStep } from './history.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -19,34 +20,46 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, retry, maxRetries, delayMs } = event.data + const { turn, step, provider, mode, retry, delayMs } = event.data if (!Number.isSafeInteger(retry) || retry < 1) { fail('llm/retry retry must be a positive safe integer') } - if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { - fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + if (typeof provider !== 'string' || provider.length === 0) { + fail('llm/retry provider must be non-empty string') } - if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) { - fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`) - } - - const currentTurnEvents: SessionEvent[] = [] - let openTurn: number | undefined - for (const prior of history.slice().reverse()) { - if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') - if (prior.type === 'turn/start') { - openTurn = prior.data.turn + switch (mode) { + case 'normal': { + const { maxRetries } = event.data + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } break } - currentTurnEvents.push(prior) + case 'always': + if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries') + break + default: + fail(`llm/retry mode must be normal or always, got ${String(mode)}`) } - if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') + if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) + || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) { + fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) + } + + const turnStartIndex = history.findLastIndex(prior => + prior.type === 'turn/start' || prior.type === 'turn/end') + const turnBoundary = history[turnStartIndex] + if (turnBoundary?.type !== 'turn/start') { + fail('llm/retry must be appended inside an open turn') + } + const openTurn = turnBoundary.data.turn if (turn !== openTurn) { fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) } + const currentTurnEvents = history.slice(turnStartIndex + 1) let closedStep: number | undefined - for (const prior of currentTurnEvents) { + for (const prior of currentTurnEvents.slice().reverse()) { if (prior.type === 'step/start') { fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) } @@ -58,15 +71,26 @@ function validateRetry( if (closedStep === undefined || step !== closedStep) { fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) } + const routedProvider = providerForClosedStep(history, turn, step) + if (routedProvider !== provider) { + fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`) + } const priorRetries = currentTurnEvents .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') if (priorRetries.some(prior => prior.data.step === step)) { fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) } - const priorRetry = priorRetries[0] - if (priorRetry !== undefined && retry <= priorRetry.data.retry) { - fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`) + const lastSuccessIndex = currentTurnEvents.findLastIndex(prior => prior.type === 'assistant/message') + const priorPolicyRetry = currentTurnEvents.findLast((prior, index): prior is SessionEvent<'llm/retry'> => ( + index > lastSuccessIndex + && prior.type === 'llm/retry' + && prior.data.provider === provider + && prior.data.mode === mode + )) + const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1 + if (retry !== expectedRetry) { + fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) } } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 7f9bc6b061..c9c5d2c564 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -4,6 +4,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { providerForClosedStep } from '../src/history.ts' async function setup(): Promise { const ctx = new Context() @@ -17,33 +18,116 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { const session = ctx.sessions.create(SessionId(id)) session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn, step }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn, step }) return session } const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } +const normal = { provider: 'mock', mode: 'normal' as const } describe('llm-retry invariants', () => { + it('has no provider without the requested closed step', () => { + expect(providerForClosedStep([], 1, 1)).toBeUndefined() + expect(providerForClosedStep([{ + type: 'step/end', + data: { turn: 1, step: 1 }, + }] as never, 1, 1)).toBeUndefined() + }) + + it('does not inherit a provider across a turn boundary', () => { + expect(providerForClosedStep([ + { type: 'turn/start', data: { turn: 1 } }, + { + type: 'request/header', + data: { header: { config: { provider: 'prior' } } }, + }, + { type: 'turn/end', data: { turn: 1 } }, + { type: 'turn/start', data: { turn: 2 } }, + { type: 'step/end', data: { turn: 2, step: 1 } }, + ] as never, 2, 1)).toBeUndefined() + }) + it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-valid') expect(() => { session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 500, failure, }) session.append('step/start', { turn: 1, step: 2 }) session.append('step/end', { turn: 1, step: 2 }) session.append('llm/retry', { - turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, + turn: 1, step: 2, ...normal, retry: 2, maxRetries: 2, delayMs: 1_000, failure, }) const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') zeroDelay.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 1, delayMs: 0, failure, }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) + it('accepts unbounded always records without serializing an infinite maximum', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-always') + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 500, + failure, + }) + }).not.toThrow() + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + maxRetries: 2, + delayMs: 500, + failure, + } as never) + }).toThrow(/always mode must omit maxRetries/) + }) + + it('rejects empty providers and unknown modes from hostile durable input', async () => { + const ctx = await setup() + const emptyProvider = closeStep(ctx, 'retry-invariant-empty-provider') + expect(() => { + emptyProvider.append('llm/retry', { + turn: 1, + step: 1, + provider: '', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/provider must be non-empty/) + + const unknownMode = closeStep(ctx, 'retry-invariant-unknown-mode') + expect(() => { + unknownMode.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'sometimes', + retry: 1, + delayMs: 1, + failure, + } as never) + }).toThrow(/mode must be normal or always/) + }) + it.each([ [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], @@ -56,7 +140,7 @@ describe('llm-retry invariants', () => { const ctx = await setup() const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) expect(() => { - session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + session.append('llm/retry', { turn: 1, step: 1, ...normal, ...data, failure }) }).toThrow(message) }) @@ -65,14 +149,14 @@ describe('llm-retry invariants', () => { const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) expect(() => { absent.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/inside an open turn/) const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') expect(() => { wrongTurn.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 2, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/open turn is 1/) @@ -81,7 +165,7 @@ describe('llm-retry invariants', () => { openStep.append('step/start', { turn: 1, step: 1 }) expect(() => { openStep.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/step 1 is still open/) @@ -89,14 +173,14 @@ describe('llm-retry invariants', () => { noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => { noStep.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/latest closed step is undefined/) const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') expect(() => { wrongStep.append('llm/retry', { - turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 2, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/latest closed step is 1/) @@ -104,34 +188,97 @@ describe('llm-retry invariants', () => { closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(() => { closedTurn.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/inside an open turn/) }) + it('binds the policy provider to the failed step rather than a later header', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-provider') + session.append('request/header', { + header: { config: { provider: 'other', model: 'mock' } }, + reason: 'change', + }) + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).not.toThrow() + + const mismatch = closeStep(ctx, 'retry-invariant-provider-mismatch') + expect(() => { + mismatch.append('llm/retry', { + turn: 1, + step: 1, + provider: 'other', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/does not match the failed request provider mock/) + }) + + it('rejects a current-turn retry without a current-turn provider route', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-prior-route') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('step/end', { turn: 2, step: 1 }) + expect(() => { + session.append('llm/retry', { + turn: 2, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/does not match the failed request provider undefined/) + }) + + it('rejects non-numeric durable delays', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-delay-type') + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: '1', failure, + } as never) + }).toThrow(/delayMs must be a finite number/) + }) + it('rejects duplicate and non-increasing retry records', async () => { const ctx = await setup() const duplicate = closeStep(ctx, 'retry-invariant-duplicate') duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) expect(() => { duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 2, maxRetries: 3, delayMs: 1, failure, }) }).toThrow(/duplicates the retry record/) const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') nonIncreasing.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) nonIncreasing.append('step/start', { turn: 1, step: 2 }) nonIncreasing.append('step/end', { turn: 1, step: 2 }) expect(() => { nonIncreasing.append('llm/retry', { - turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 2, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) - }).toThrow(/must increase/) + }).toThrow(/must equal provider policy retry 2/) }) it('validates existing histories on late registration', async () => { @@ -140,7 +287,7 @@ describe('llm-retry invariants', () => { const session = ctx.sessions.create(SessionId('retry-invariant-late')) session.append('step/end', { turn: 1, step: 1 }) session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1aafc91d92..3ecc5985fe 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -9,8 +9,8 @@ import Include from '@cordisjs/plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -21,6 +21,16 @@ let context: Context | undefined class TransientOnceAdapter extends LlmAdapter { requests = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT', 'SERVER'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'loader test provider retryPolicy') + + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } async * stream(_options: GenerateOptions): AsyncIterable { this.requests += 1 @@ -87,7 +97,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('loads the flat policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { + it('loads provider-supplied policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", @@ -95,12 +105,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-llm-retry'", - ' config:', - ' maxTransientRetries: 1', - ' initialDelayMs: 1', - ' maxDelayMs: 1', - ' jitterRatio: 0', - ' retryableCodes: [RATE_LIMIT, SERVER]', "- name: '@deepseek-ai/dsh-agent-loop'", ]) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 1668c36d73..41c70b257d 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -34,12 +34,17 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) const session = ctx.sessions.create(SessionId(`retry-${kind}`)) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn: 1, step: 1 }) const event = session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', retry: 1, - maxRetries: 2, delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, }) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..238385baf5 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' -import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { + AlwaysRetryPolicyConfig, + BackoffConfig, + GenerateOptions, + NormalRetryPolicyConfig, + ResolvedRetryPolicy, + RetryPolicyConfig, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -10,13 +18,13 @@ import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] + private retryPolicies: Readonly> = {} constructor(private readonly entries: ScriptEntry[]) { super() @@ -29,6 +37,21 @@ class ScriptedAdapter extends LlmAdapter { if (entry instanceof Error) throw entry yield* entry } + + configureRetryPolicies( + policies: Readonly>, + ): void { + this.retryPolicies = Object.fromEntries(Object.entries(policies).map(([provider, policy]) => [ + provider, + policy === undefined + ? undefined + : resolveRetryPolicy(policy, `retry test provider "${provider}" retryPolicy`), + ])) + } + + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.retryPolicies[provider] + } } async function* partialToolFailure(error: Error): AsyncGenerator { @@ -52,8 +75,8 @@ function textResponse(text: string): StreamChunk[] { } async function harness( - adapter: LlmAdapter, - config: retry.Config = {}, + adapter: ScriptedAdapter, + policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, internals: retry.RetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber }> { @@ -64,20 +87,44 @@ async function harness( await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) - const resolvedConfig = Object.assign({ - maxTransientRetries: 2, - initialDelayMs: 500, - maxDelayMs: 10_000, - jitterRatio: 0, - }, config) + adapter.configureRetryPolicies(policies) const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, resolvedConfig, internals) + retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + ctx.llm.registerAdapter(['mock', 'other'], adapter) return { ctx, retryFiber } } +function normalConfig( + overrides: Partial> = {}, +): NormalRetryPolicyConfig { + const { backoff, ...policy } = overrides + return { + mode: 'normal', + maxRetries: 2, + ...policy, + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } +} + +function alwaysConfig(backoff: BackoffConfig = {}): AlwaysRetryPolicyConfig { + return { + mode: 'always', + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } +} + function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -108,14 +155,14 @@ afterEach(async () => { context = undefined }) -describe('bounded transient retry policy', () => { +describe('provider-routed retry policy', () => { it('records the scheduled delay before opening a fresh request attempt', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('busy', 'RATE_LIMIT', { status: 429 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter)) + ;({ ctx: context } = await harness(adapter, {}, undefined, { random: () => 0.5 })) const agent = context.agentLoop.create(SessionId('retry-success'), { provider: 'mock', model: 'mock', @@ -135,6 +182,8 @@ describe('bounded transient retry policy', () => { expect(event.data).toEqual({ turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -207,7 +256,9 @@ describe('bounded transient retry policy', () => { new LlmError('busy two', 'SERVER'), new LlmError('busy three', 'SERVER'), ]) - ;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, { + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { jitterRatio: 0.1 }, + }) }, undefined, { random: () => samples.shift() ?? 0.5, })) const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) @@ -238,11 +289,9 @@ describe('bounded transient retry policy', () => { new LlmError('busy', 'SERVER'), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter, { - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 1, - }, undefined, { random: () => 0 })) + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 1 }, + }) }, undefined, { random: () => 0 })) const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) @@ -261,7 +310,9 @@ describe('bounded transient retry policy', () => { new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) + ;({ ctx: context } = await harness(accepted, { mock: normalConfig({ + backoff: { jitterRatio: 1 }, + }) })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) acceptedAgent.send([{ type: 'text', text: 'go' }]) @@ -284,6 +335,32 @@ describe('bounded transient retry policy', () => { expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) }) + it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('wait too long', 'AUTH', { providerRetryAfterMs: 10 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 2, + maxDelayMs: 4, + jitterRatio: 0.5, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-over-cap'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(3) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(3) + await idle + + expect(adapter.requests).toHaveLength(2) + }) + it('delegates non-transient failures without scheduling a timer', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) @@ -297,13 +374,203 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) + it('selects policy by the failed request provider', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('mock auth failed', 'AUTH'), + new LlmError('other auth failed', 'AUTH'), + textResponse('other recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + })) + + const normalAgent = context.agentLoop.create(SessionId('retry-provider-normal'), { + provider: 'mock', + model: 'mock', + }) + const normalIdle = waitForIdle(context, normalAgent) + normalAgent.send([{ type: 'text', text: 'normal' }]) + await normalIdle + expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + + const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { + provider: 'other', + model: 'mock', + }) + const scheduled = waitForRetry(context, alwaysAgent, 1) + alwaysAgent.send([{ type: 'text', text: 'always' }]) + expect((await scheduled).data).toMatchObject({ + provider: 'other', + mode: 'always', + retry: 1, + delayMs: 1, + }) + const alwaysIdle = waitForIdle(context, alwaysAgent) + await vi.advanceTimersByTimeAsync(1) + await alwaysIdle + + expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + }) + + it('selects an always policy from the provider chosen by agent/request', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('rerouted auth failed', 'AUTH'), + textResponse('rerouted recovery'), + ]) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + }, (ctx) => { + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ + ...config, + provider: 'other', + })) + })) + const agent = context.agentLoop.create(SessionId('retry-provider-rerouted'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'reroute' }]) + expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always' }) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests.map(request => request.provider)).toEqual(['other', 'other']) + }) + + it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('auth one', 'AUTH'), + new LlmError('auth two', 'AUTH'), + new LlmError('auth three', 'AUTH'), + new LlmError('auth four', 'AUTH'), + textResponse('eventually recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 4, + jitterRatio: 0.1, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-unbounded'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'keep trying' }]) + await vi.runAllTimersAsync() + await idle + + const events = agent.session.events.filter(event => event.type === 'llm/retry') + expect(adapter.requests).toHaveLength(5) + expect(events.map(event => ({ + provider: event.data.provider, + mode: event.data.mode, + retry: event.data.retry, + delayMs: event.data.delayMs, + hasMax: 'maxRetries' in event.data, + }))).toEqual([ + { provider: 'mock', mode: 'always', retry: 1, delayMs: 1.1, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 2, delayMs: 2.2, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 3, delayMs: 4, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 4, delayMs: 4, hasMax: false }, + ]) + }) + + it('keeps failed error text and partial output out of every retried model context', async () => { + vi.useFakeTimers() + const diagnostic = 'private provider diagnostic must not enter context' + const adapter = new ScriptedAdapter([ + partialToolFailure(new LlmError(diagnostic, 'AUTH')), + textResponse('recovered without leaked context'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + const agent = context.agentLoop.create(SessionId('retry-always-context-isolation'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'safe input' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[1]?.messages).toEqual(adapter.requests[0]?.messages) + const retriedContext = JSON.stringify(adapter.requests[1]?.messages) + expect(retriedContext).not.toContain(diagnostic) + expect(retriedContext).not.toContain('discarded partial output') + expect(agent.session.events.some(event => + event.type === 'llm/retry' && event.data.failure.message === diagnostic, + )).toBe(true) + }) + + it('lets downstream specialized recovery run before always fallback', async () => { + const adapter = new ScriptedAdapter([ + new LlmError('requires specialized recovery', 'AUTH'), + textResponse('specialized recovery won'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) + context.on('agent/request-error', async () => ({ action: 'retry' })) + const agent = context.agentLoop.create(SessionId('retry-always-composition'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'recover' }]) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + }) + + it.each([ + ['synchronously', () => { throw new Error('downstream recovery failed') }], + ['asynchronously', async () => { throw new Error('downstream recovery failed') }], + ])('falls back to always retry when downstream recovery throws %s', async (_kind, failDownstream) => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('requires fallback', 'AUTH'), + textResponse('always recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + context.on('agent/request-error', failDownstream) + const agent = context.agentLoop.create(SessionId('retry-always-downstream-error'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'recover' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + }) + it('aborts and drains a captured backoff before plugin disposal completes', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('temporary', 'TRANSPORT'), textResponse('must not run'), ]) - const mounted = await harness(adapter) + const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) @@ -322,7 +589,7 @@ describe('bounded transient retry policy', () => { it('does not make plugin disposal wait for a delegated recovery policy', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) - const mounted = await harness(adapter) + const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() @@ -353,6 +620,61 @@ describe('bounded transient retry policy', () => { expect(adapter.requests).toHaveLength(1) }) + it('lets turn cancellation interrupt a delegated recovery policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + context.on('agent/request-error', () => { + entered.resolve(undefined) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await entered.promise + + agent.cancel({ kind: 'user' }) + await idle + downstream.resolve({ action: 'fail' }) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('handles synchronous cancellation while entering delegated recovery', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + context.on('agent/request-error', (agent) => { + agent.cancel({ kind: 'user' }) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + downstream.resolve({ action: 'fail' }) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + it('fails a captured callback after disposal without entering downstream policy', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) const captured = Promise.withResolvers() @@ -391,10 +713,10 @@ describe('bounded transient retry policy', () => { it('lets turn cancellation win during backoff without opening another step', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ - new LlmError('temporary', 'TIMEOUT'), + new LlmError('permanent', 'AUTH'), textResponse('must not run'), ]) - ;({ ctx: context } = await harness(adapter)) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.send([{ type: 'text', text: 'go' }]) @@ -411,13 +733,16 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('lets an earlier recovery listener cancel before retry policy runs', async () => { + it.each([ + ['normal', normalConfig()], + ['always', alwaysConfig()], + ])('lets an earlier recovery listener cancel before %s retry policy runs', async (_mode, policy) => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('temporary', 'SERVER'), textResponse('must not run'), ]) - ;({ ctx: context } = await harness(adapter, {}, (ctx) => { + ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { agent.cancel({ kind: 'user' }) return next() @@ -458,19 +783,15 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it.each([ - [{ maxTransientRetries: -1 }, /maxTransientRetries/], - [{ maxTransientRetries: 1.5 }, /maxTransientRetries/], - [{ initialDelayMs: 0 }, /initialDelayMs/], - [{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/], - [{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/], - [{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/], - [{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/], - [{ jitterRatio: 1.1 }, /jitterRatio/], - [{ retryableCodes: [] }, /must not be empty/], - [{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], - [{ retryableCodes: [''] }, /non-empty strings/], - ] as const)('fails direct composition for invalid config %#', (config, message) => { - expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message) + it('rejects retry policy configured on the executor instead of a provider', () => { + expect(() => { + retry.apply(new Context(), { retryPolicy: { mode: 'always' } }) + }).toThrow(/retryPolicy belongs under each provider/) + }) + + it('rejects unknown executor config', () => { + expect(() => { + retry.apply(new Context(), { retryPolciy: {} }) + }).toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/llm-retry/tsdown.config.ts b/packages/llm/llm-retry/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/llm/llm-retry/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index beac6d5e8e..74dafa3e48 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,13 +10,14 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. -Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. @@ -28,7 +29,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use bounded normal retry policy, use the route id as its name, advertise no models, and return no capacity. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -69,7 +70,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel ## Known Limitations and Deferred Work -- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine. +- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores retry policy, but `llm/stream` remains a single-attempt call-wrapper seam. The agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure; `@deepseek-ai/dsh-llm-retry` is the optional executor loaded by the shared example spine. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cc13bc183e..d5b0298e50 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -38,11 +38,16 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..773b9a5ca1 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -16,6 +16,8 @@ import type { Message, StreamChunk, } from './types.ts' +import { resolveRetryPolicy } from './retry-policy.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' @@ -27,6 +29,7 @@ export * from './brand.ts' export * from './never.ts' export * from './error.ts' export * from './types.ts' +export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' @@ -119,6 +122,15 @@ export abstract class LlmAdapter { return { id: provider, name: provider } } + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { + return undefined + } + /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and @@ -157,7 +169,11 @@ export abstract class LlmAdapter { * surface, interceptable via the `llm/stream` waterfall. */ export class LlmService extends Service { - private adapters = new Map() + private adapters = new Map() constructor(ctx: Context) { super(ctx, 'llm') @@ -175,7 +191,11 @@ export class LlmService extends Service { const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') const unique = new Set() - const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = [] + const registrations: { + adapter: LlmAdapter + provider: LlmProviderInfo + retryPolicy: ResolvedRetryPolicy + }[] = [] for (const provider of providers) { if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') if (unique.has(provider) || this.adapters.has(provider)) { @@ -186,7 +206,13 @@ export class LlmService extends Service { throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') } unique.add(provider) - registrations.push({ adapter, provider: { id: info.id, name: info.name } }) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) } for (const registration of registrations) this.adapters.set(registration.provider.id, registration) yield () => { @@ -206,6 +232,15 @@ export class LlmService extends Service { return [...this.adapters.values()].map(({ provider }) => ({ ...provider })) } + /** + * Resolve the retry policy captured when one provider route was registered. + * @param provider - registered provider route to inspect. + * @returns the provider-owned policy, with normal defaults already resolved. + */ + providerRetryPolicy(provider: string): ResolvedRetryPolicy { + return this.registration(provider).retryPolicy + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -262,7 +297,11 @@ export class LlmService extends Service { return { contextWindow: context.contextWindow } } - private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { + private registration(provider: string): { + adapter: LlmAdapter + provider: LlmProviderInfo + retryPolicy: ResolvedRetryPolicy + } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') return registration diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts new file mode 100644 index 0000000000..a1932d50a2 --- /dev/null +++ b/packages/llm/llm/src/retry-policy.ts @@ -0,0 +1,184 @@ +/** + * Provider-owned request-retry policy configuration and resolution. + * + * Adapters expose one resolved policy per registered provider route; the + * optional dsh-llm-retry plugin executes it on the agent's failed-step seam. + * + * @module @deepseek-ai/dsh-llm/retry-policy + */ + +import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_INITIAL_DELAY_MS = 500 +const DEFAULT_MAX_DELAY_MS = 10_000 +const DEFAULT_JITTER_RATIO = 0.1 +const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) + +/** Bounded exponential backoff with symmetric jitter around each local delay. */ +export interface BackoffConfig { + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number +} + +/** Current bounded transient retry behavior for one provider route. */ +export interface NormalRetryPolicyConfig { + /** Retry only configured transient failure codes. */ + mode: 'normal' + /** Maximum eligible retries after the first request (default 2). */ + maxRetries?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Unbounded retry behavior for every model-request failure on one provider route. */ +export interface AlwaysRetryPolicyConfig { + /** Retry every model-request failure until success, cancellation, or disposal. */ + mode: 'always' + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Provider-owned model-request retry policy configuration. */ +export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig + +/** Fully resolved backoff shared by both retry modes. */ +export interface ResolvedRetryBackoff { + readonly initialDelayMs: number + readonly maxDelayMs: number + readonly jitterRatio: number +} + +/** Fully resolved bounded transient retry policy. */ +export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'normal' + readonly maxRetries: number + readonly retryableCodes: readonly string[] +} + +/** Fully resolved unbounded retry policy. */ +export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'always' +} + +/** Immutable provider policy captured when its adapter route is registered. */ +export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy + +const backoffSchema: z = z.object({ + initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), + maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), + jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), +}) + +const normalPolicySchema: z = z.object({ + mode: z.const('normal').required(), + maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES), + retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), + backoff: backoffSchema, +}) + +const alwaysPolicySchema: z = z.object({ + mode: z.const('always').required(), + backoff: backoffSchema, +}) + +/** Cordis schema embedded by each concrete provider configuration. */ +export const RetryPolicySchema: z = z.union([ + normalPolicySchema, + alwaysPolicySchema, +]) + +const NORMAL_POLICY_KEYS: ReadonlySet = new Set([ + 'mode', 'maxRetries', 'retryableCodes', 'backoff', +]) +const ALWAYS_POLICY_KEYS: ReadonlySet = new Set(['mode', 'backoff']) +const BACKOFF_KEYS: ReadonlySet = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']) + +function validateKeys(value: object, allowed: ReadonlySet, path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}: unknown key "${key}"`) + } +} + +function resolveBackoff(config: BackoffConfig | undefined, path: string): ResolvedRetryBackoff { + if (config !== undefined) validateKeys(config, BACKOFF_KEYS, path) + const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS + const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS + const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO + + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) + } + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error(`${path}.jitterRatio must be between 0 and 1`) + } + + return Object.freeze({ initialDelayMs, maxDelayMs, jitterRatio }) +} + +/** + * Validate, default, and detach one provider-owned retry policy. + * @param config - optional provider configuration; omission selects normal defaults. + * @param path - diagnostic path naming the provider config that owns the value. + * @returns an immutable policy safe to capture in provider registration state. + */ +export function resolveRetryPolicy( + config: RetryPolicyConfig | undefined, + path: string, +): ResolvedRetryPolicy { + if (config === undefined) { + return Object.freeze({ + mode: 'normal', + maxRetries: DEFAULT_MAX_RETRIES, + retryableCodes: DEFAULT_RETRYABLE_CODES, + ...resolveBackoff(undefined, `${path}.backoff`), + }) + } + + switch (config.mode) { + case 'normal': { + validateKeys(config, NORMAL_POLICY_KEYS, path) + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES + const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new Error(`${path}.maxRetries must be a non-negative safe integer`) + } + if (retryableCodes.length === 0) { + throw new Error(`${path}.retryableCodes must not be empty`) + } + if (retryableCodes.some(code => code.length === 0)) { + throw new Error(`${path}.retryableCodes must contain only non-empty strings`) + } + if (new Set(retryableCodes).size !== retryableCodes.length) { + throw new Error(`${path}.retryableCodes must not contain duplicates`) + } + return Object.freeze({ + mode: 'normal', + maxRetries, + retryableCodes: Object.freeze([...retryableCodes]), + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + } + case 'always': + validateKeys(config, ALWAYS_POLICY_KEYS, path) + return Object.freeze({ + mode: 'always', + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + default: + throw new Error(`${path}.mode must be "normal" or "always"`) + } +} diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts new file mode 100644 index 0000000000..d262533787 --- /dev/null +++ b/packages/llm/llm/tests/retry-policy.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + resolveRetryPolicy, + RetryPolicySchema, +} from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +describe('provider retry policy', () => { + it('resolves immutable normal defaults', () => { + const policy = resolveRetryPolicy(undefined, 'provider.retryPolicy') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 2, + retryableCodes: ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'], + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(Object.isFrozen(policy)).toBe(true) + if (policy.mode !== 'normal') throw new Error('expected normal policy') + expect(Object.isFrozen(policy.retryableCodes)).toBe(true) + }) + + it('resolves and detaches a configured normal policy', () => { + const retryableCodes = ['BUSY'] + const config: RetryPolicyConfig = { + mode: 'normal', + maxRetries: 4, + retryableCodes, + backoff: { + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }, + } + + const policy = resolveRetryPolicy(config, 'provider.retryPolicy') + retryableCodes.push('LATE') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 4, + retryableCodes: ['BUSY'], + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }) + }) + + it('resolves always mode with default backoff', () => { + expect(resolveRetryPolicy({ mode: 'always' }, 'provider.retryPolicy')).toEqual({ + mode: 'always', + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(RetryPolicySchema).toBeDefined() + }) + + it.each([ + [{ mode: 'normal', maxRetries: -1 }, /maxRetries/], + [{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/], + [{ mode: 'normal', maxRetries: Number.MAX_SAFE_INTEGER + 1 }, /maxRetries/], + [{ mode: 'always', backoff: { initialDelayMs: 0 } }, /initialDelayMs/], + [{ mode: 'normal', backoff: { maxDelayMs: Number.POSITIVE_INFINITY } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /initialDelayMs/], + [{ mode: 'always', backoff: { maxDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: 20, maxDelayMs: 10 } }, /less than or equal/], + [{ mode: 'always', backoff: { jitterRatio: 1.1 } }, /jitterRatio/], + [{ mode: 'normal', retryableCodes: [] }, /must not be empty/], + [{ mode: 'normal', retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], + [{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/], + [{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/], + [{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/], + [{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/], + [{ mode: 'sometimes' }, /mode must be "normal" or "always"/], + ] as const)('rejects invalid policy %#', (config, message) => { + expect(() => { + resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy') + }).toThrow(message) + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 90be1ffcb0..52c51852ba 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -11,6 +11,7 @@ import LlmService, { LlmError, llmFailureOf, ProviderRequestId, + resolveRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' @@ -159,6 +160,27 @@ describe('LlmService', () => { expect(chunks).toEqual(SCRIPT) }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { + const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') + const adapter = new class extends ScriptedAdapter { + override providerRetryPolicy(provider: string) { + return provider === 'configured' ? configured : undefined + } + }(SCRIPT) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['configured', 'defaulted'], adapter) + + expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) + expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) + expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow( + expect.objectContaining({ code: 'NO_ADAPTER' }), + ) + }) + it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 5bc7a9fcf5..fa4adda095 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8a731a1bf6..d1e78a0e35 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1366,8 +1366,9 @@ export function streamSessionEventUpdate( return } case 'llm/retry': { + const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) const text = '\n\n[Previous model attempt discarded; retrying ' - + `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: ` + + `${event.data.retry}/${retryLimit} in ${event.data.delayMs}ms: ` + `${event.data.failure.message}]\n\n` notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 2585968b7e..08993f7ce1 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -103,6 +103,8 @@ describe('streamSessionEventUpdate', () => { expect(updatesFor(evt('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -114,6 +116,21 @@ describe('streamSessionEventUpdate', () => { text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n', }, }]) + expect(updatesFor(evt('llm/retry', { + turn: 1, + step: 2, + provider: 'mock', + mode: 'always', + retry: 7, + delayMs: 1_000, + failure: { message: 'still unavailable', code: 'AUTH' }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Previous model attempt discarded; retrying 7/∞ in 1000ms: still unavailable]\n\n', + }, + }]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2ade1937e3..bfe199e13b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2298,8 +2298,9 @@ export function createTuiChat( } case 'llm/retry': { clearStreaming() + const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) appendNotice( - `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, + `Retrying model request (${event.data.retry}/${retryLimit}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', ) break diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index accef4fffc..39862c8260 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -21,7 +21,7 @@ buffer 7| "▌ " style 0-0 fg=bright-blue 8| -9| " Retrying model request (1/2) in 1000ms: temporary transport failure " +9| " Retrying model request (1/∞) in 1000ms: temporary transport failure " style 1-67 fg=yellow 10| 11| " Turn cancelled. " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 16d6794002..1aec66d631 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -269,6 +269,8 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -297,8 +299,9 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', retry: 1, - maxRetries: 2, delayMs: 1_000, failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 73be9abe76..6af58fe97e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1290,6 +1290,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -1322,6 +1324,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -1330,6 +1334,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 2, + provider: 'mock', + mode: 'normal', retry: 2, maxRetries: 2, delayMs: 1_000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..7b19523d33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2217,6 +2217,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -2224,6 +2228,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d87fba3d08..3394801d3d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -41,6 +41,7 @@ export const LINK_MAP: Record = { LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', + ResolvedRetryPolicy: 'llm-streaming.md', Message: 'core.md', MessageSource: 'core.md', PromptDecision: 'core.md', From 4c6618c26da6ff0fe08b1881be31d19ceebe20c4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 10:36:03 +0800 Subject: [PATCH 02/41] test(llm): adapt retry coverage to followup API --- packages/llm/llm-retry/tests/retry.spec.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index fc43bb774f..1c83d3bacc 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -352,7 +352,7 @@ describe('provider-routed retry policy', () => { }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) expect((await scheduled).data.delayMs).toBe(3) const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(3) @@ -390,7 +390,7 @@ describe('provider-routed retry policy', () => { model: 'mock', }) const normalIdle = waitForIdle(context, normalAgent) - normalAgent.send([{ type: 'text', text: 'normal' }]) + normalAgent.followup([{ type: 'text', text: 'normal' }]) await normalIdle expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) @@ -399,7 +399,7 @@ describe('provider-routed retry policy', () => { model: 'mock', }) const scheduled = waitForRetry(context, alwaysAgent, 1) - alwaysAgent.send([{ type: 'text', text: 'always' }]) + alwaysAgent.followup([{ type: 'text', text: 'always' }]) expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always', @@ -433,7 +433,7 @@ describe('provider-routed retry policy', () => { }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'reroute' }]) + agent.followup([{ type: 'text', text: 'reroute' }]) expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always' }) const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(1) @@ -462,7 +462,7 @@ describe('provider-routed retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'keep trying' }]) + agent.followup([{ type: 'text', text: 'keep trying' }]) await vi.runAllTimersAsync() await idle @@ -499,7 +499,7 @@ describe('provider-routed retry policy', () => { }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'safe input' }]) + agent.followup([{ type: 'text', text: 'safe input' }]) await scheduled const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(1) @@ -528,7 +528,7 @@ describe('provider-routed retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'recover' }]) + agent.followup([{ type: 'text', text: 'recover' }]) await idle expect(adapter.requests).toHaveLength(2) @@ -555,7 +555,7 @@ describe('provider-routed retry policy', () => { }) const scheduled = waitForRetry(context, agent, 1) - agent.send([{ type: 'text', text: 'recover' }]) + agent.followup([{ type: 'text', text: 'recover' }]) await scheduled const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(1) @@ -635,7 +635,7 @@ describe('provider-routed retry policy', () => { model: 'mock', }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await entered.promise agent.cancel({ kind: 'user' }) @@ -664,7 +664,7 @@ describe('provider-routed retry policy', () => { }) const idle = waitForIdle(context, agent) - agent.send([{ type: 'text', text: 'go' }]) + agent.followup([{ type: 'text', text: 'go' }]) await idle downstream.resolve({ action: 'fail' }) From d07e7dd317c14bacf8af244057f328fa4c5c86dd Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 10:38:42 +0800 Subject: [PATCH 03/41] docs: refresh module dependency graph --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..d172cad2af 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -232,6 +232,7 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime @@ -805,7 +806,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | From c1f148ce7f4c1e5e96f50a0f13985ed971a1456c Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 10:49:48 +0800 Subject: [PATCH 04/41] fix(llm): preserve retry routes across turns --- packages/llm/llm-retry/src/history.ts | 6 +-- .../llm/llm-retry/tests/invariant.spec.ts | 8 ++-- packages/llm/llm-retry/tests/retry.spec.ts | 39 +++++++++++++++++++ .../sandbox-local/tests/packed-install.e2e.ts | 1 + 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts index 2b86a77e03..d31fe3f201 100644 --- a/packages/llm/llm-retry/src/history.ts +++ b/packages/llm/llm-retry/src/history.ts @@ -4,8 +4,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' /** * Find the provider in force when one step closed, excluding later recovery mutations. - * A preceding retry is also a route marker because every provider change - * requires a newer full request-header snapshot. + * Request headers remain effective across turn boundaries until a newer full + * snapshot changes them. A preceding retry in the same turn is also a route + * marker because every provider change requires a newer full snapshot. * @param events - session events containing the closed step. * @param turn - turn that owns the failed step. * @param step - failed step whose provider is required. @@ -32,7 +33,6 @@ export function providerForClosedStep( && event.data.step < step) { return event.data.provider } - if (event.type === 'turn/start' || event.type === 'turn/end') return undefined } return undefined } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index c9c5d2c564..aa4030a886 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -38,7 +38,7 @@ describe('llm-retry invariants', () => { }] as never, 1, 1)).toBeUndefined() }) - it('does not inherit a provider across a turn boundary', () => { + it('inherits the latest provider across a turn boundary when the header is unchanged', () => { expect(providerForClosedStep([ { type: 'turn/start', data: { turn: 1 } }, { @@ -48,7 +48,7 @@ describe('llm-retry invariants', () => { { type: 'turn/end', data: { turn: 1 } }, { type: 'turn/start', data: { turn: 2 } }, { type: 'step/end', data: { turn: 2, step: 1 } }, - ] as never, 2, 1)).toBeUndefined() + ] as never, 2, 1)).toBe('prior') }) it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { @@ -226,7 +226,7 @@ describe('llm-retry invariants', () => { }).toThrow(/does not match the failed request provider mock/) }) - it('rejects a current-turn retry without a current-turn provider route', async () => { + it('accepts a current-turn retry under an unchanged prior provider route', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-prior-route') session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -243,7 +243,7 @@ describe('llm-retry invariants', () => { delayMs: 1, failure, }) - }).toThrow(/does not match the failed request provider undefined/) + }).not.toThrow() }) it('rejects non-numeric durable delays', async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 1c83d3bacc..56f785f38c 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -207,6 +207,45 @@ describe('provider-routed retry policy', () => { }) }) + it('retries a later turn under its unchanged provider header', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + textResponse('first turn'), + new LlmError('busy on second turn', 'RATE_LIMIT'), + textResponse('second turn recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }) })) + const agent = context.agentLoop.create(SessionId('retry-later-turn'), { + provider: 'mock', + model: 'mock', + }) + + const firstIdle = waitForIdle(context, agent) + agent.followup([{ type: 'text', text: 'first' }]) + await firstIdle + expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + + const scheduled = waitForRetry(context, agent, 1) + agent.followup([{ type: 'text', text: 'second' }]) + expect((await scheduled).data).toMatchObject({ + turn: 2, + step: 1, + provider: 'mock', + }) + const secondIdle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await secondIdle + + expect(adapter.requests).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'second turn recovered' }], + }) + }) + it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9fcfe23de8..a032e1add7 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', + 'packages/util/timeout', 'packages/support/invariants', ] From a623ed518398ae9471aea6481ccb286ec7e24d45 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 10:54:30 +0800 Subject: [PATCH 05/41] test(tui): cover unbounded retry status --- packages/ui/tui/tests/tui.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d1aac9b10c..2dd3ed2098 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1345,10 +1345,20 @@ describe('pi-tui chat lifecycle and transcript', () => { delayMs: 1_000, failure: { message: 'failed before chunks', code: 'SERVER', status: 503 }, }) + result.session.append('llm/retry', { + turn: 1, + step: 3, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 2_000, + failure: { message: 'retry without limit', code: 'AUTH', status: 401 }, + }) await tick() 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).toContain('Retrying model request (1/∞) in 2000ms: retry without limit') await dispose(result) }) From 015ba14bae3bcd817b88636d375add27ee31d6cf Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 13:30:34 +0800 Subject: [PATCH 06/41] fix(llm): preserve serving retry policy --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 8 +- .../2026-07-24-provider-retry-policies.zh.md | 8 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 13 ++- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 4 +- docs/core-data-structures/llm-streaming.zh.md | 4 +- docs/event-producer-consumer.md | 8 +- packages/compact/compact-basic/src/index.ts | 1 + .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/loop.ts | 23 ++-- .../agent-loop/tests/request-recovery.spec.ts | 44 ++++++-- packages/core/agent/src/types.ts | 6 +- packages/core/scope/tests/invariant.spec.ts | 2 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/src/index.ts | 15 +-- packages/llm/llm-retry/tests/retry.spec.ts | 106 ++++++++++++++++-- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/adapter-failure.ts | 29 ++++- packages/llm/llm/src/index.ts | 8 +- packages/llm/llm/tests/service.spec.ts | 47 ++++++++ packages/plan/plan-mode/src/index.ts | 1 + .../plan/plan-mode/tests/integration.spec.ts | 4 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 5 +- 27 files changed, 278 insertions(+), 82 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index ee221716ce..000b11d7f0 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: 0ff304715aa5e1bfaf296e3f53e1da28d8b4042d -2026-07-24-provider-retry-policies.zh.md: 3b1d8f12a5fb37424e8b964f7bbb2be278263056 +2026-07-24-provider-retry-policies.md: 0b9456eb1563bfcbfa06d93403fd68124ed1f707 +2026-07-24-provider-retry-policies.zh.md: d3ef90ec739eec7300b7d0bb526cb5de958ed93f diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index 0ff304715a..0b9456eb15 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route ## Decision -Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. `@deepseek-ai/dsh-llm-retry` reads the registered policy for the provider whose step failed. A provider without `retryPolicy` uses the normal defaults. +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without `retryPolicy` uses the normal defaults. ```yaml providers: @@ -34,7 +34,7 @@ providers: jitterRatio: 0.2 ``` -The listener selects the policy from the durable `request/header` in force when the failed step closed, excluding later recovery mutations. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. +The listener reads the provider from the durable `request/header` in force when the failed step closed, excluding later recovery mutations, but never re-resolves policy from the mutable provider registry. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. Success, turn cancellation, and plugin disposal are the only termination paths. @@ -56,10 +56,10 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load and prove registration captures configured and default policies. Unit and real-Loader composition tests select policies from the failed request's provider, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation interrupts stalled downstream recovery, and prove cancellation and disposal stop active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; ACP and TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and real-Loader composition tests select policies from the failed request's serving registration, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation interrupts stalled downstream recovery, and prove cancellation and disposal stop active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; ACP and TUI tests render finite and infinite limits. ## Consequences -Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and exact-provider selection keeps one provider's exceptional policy from changing another provider's recovery behavior. +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 3b1d8f12a5..d3ef90ec73 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。`@deepseek-ai/dsh-llm-retry` 读取失败步骤对应提供方的已注册策略。未配置 `retryPolicy` 的提供方使用 normal 默认值。 +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose 或替换,agent loop 仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。 ```yaml providers: @@ -34,7 +34,7 @@ providers: jitterRatio: 0.2 ``` -监听器根据失败步骤关闭时生效的持久 `request/header` 选择策略,后续恢复产生的改动不参与选择。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 +监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。成功、轮次取消和插件 dispose(资源释放)是仅有的终止路径。 @@ -56,10 +56,10 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,并证明注册流程会捕获已配置策略和默认策略。单元测试与真实 Loader 组合测试根据失败请求的提供方选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消会中断停滞的下游恢复,并证明取消与 dispose 会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;ACP 与 TUI 测试会分别渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与真实 Loader 组合测试根据失败请求实际使用的注册项选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消会中断停滞的下游恢复,并证明取消与 dispose 会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;ACP 与 TUI 测试会分别渲染有限和无限上限。 ## 后果 -normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;精确提供方选择也能避免某个提供方的例外策略改变其他提供方的恢复行为。 +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复契约。 本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭 step 恢复、单次可见适配器尝试、结构化失败与持久状态设计。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2036a34307..9219ee0f10 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -646,11 +646,11 @@ Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` · `llm` +Requires: `agents` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ -export type Config = Readonly> +export type Config = Readonly> ``` Source: [`packages/llm/llm-retry/src/index.ts:43`](../packages/llm/llm-retry/src/index.ts) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index b6a15c19b6..941c92eacc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:498`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:499`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -281,16 +281,17 @@ Recover a model-request failure after its failed step has closed. `retry` opens * @param error - the original model-request failure. * @param failure - serializable facts normalized at the final adapter boundary. * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. + * @param retryPolicy - immutable policy of the adapter registration that served the failed request, or `undefined` if no final adapter served it. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -403,7 +404,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:474`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:475`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -425,7 +426,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:486`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 0d7edf9dfe..d7bab0aa3e 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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 -llm-streaming.md: 2917d9796b32956f11ed5703f1f34c7510d0526c -llm-streaming.zh.md: 1bf7b3c226b5878deff3252396d96c2e97bf2982 +llm-streaming.md: 2d185e694f272114d4efa4f6be9020ef7c9a950f +llm-streaming.zh.md: 9734c643724e19fc39aff2ee94bd8de61935e16c diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 2917d9796b..2d185e694f 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -59,7 +59,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error, facts, and immutable prior-retried facts to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, and serving policy to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. @@ -70,7 +70,7 @@ This contract is pinned down by two deliberately independent implementations: `d ## `ResolvedRetryPolicy` -Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the captured value and supplies normal defaults when the adapter omits one. The [generated config catalog](../config-catalog.md) owns the optional input shapes. +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the exact serving registration's captured value after that call enters its final adapter boundary, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) owns the optional input shapes. ## `AppIdentity` — app attribution diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 1bf7b3c226..9734c64372 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,7 +59,7 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实以及实际提供服务的注册项所持不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误、事实、不可变的先前已重试事实与实际提供服务的策略提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 @@ -70,7 +70,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -提供方配置会在路由注册前解析为不可变的可辨识联合类型。normal 模式包含 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 和 `jitterRatio`;always 模式包含 `mode: 'always'` 和相同的必填退避字段,但不含有限上限。`LlmService.providerRetryPolicy(provider)` 返回捕获的值;适配器未提供策略时,该方法会补上 normal 默认值。可选输入形状由[生成的配置目录](../config-catalog.md)定义。 +提供方配置会在路由注册前解析为不可变的可辨识联合类型。normal 模式包含 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 和 `jitterRatio`;always 模式包含 `mode: 'always'` 和相同的必填退避字段,但不含有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前已注册的值;适配器未提供策略时,该方法会补上 normal 默认值。调用进入最终适配器边界后,`llmRetryPolicyOf(stream)` 返回实际提供服务的确切注册项所捕获的值,因此后续路由 dispose 或替换无法改变请求进行期间发生的失败所用恢复策略。可选输入形状由[生成的配置目录](../config-catalog.md)定义。 ## `AppIdentity`:应用归属 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 08dcc19d54..3a2802588b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:499`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -19,13 +19,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:475`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:486`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 10c64d10ca..0ae0e02f7b 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -160,6 +160,7 @@ export class BasicCompactService extends CompactService { _error, failure, priorFailures, + _retryPolicy, signal, next, ) => { diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1db86d38e4..711dc3ee95 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1277,7 +1277,7 @@ describe('automatic listener and loader composition', () => { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) return agentEvents(ctx, owner).waterfall( - 'agent/request-error', 1, 1, error, failure, priorFailures, signal, next, + 'agent/request-error', 1, 1, error, failure, priorFailures, undefined, signal, next, ) } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8284aa022c..acbf9c74ef 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -941,8 +941,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param retryPolicy - immutable policy of the adapter registration that served the failed request, or `undefined` if no final adapter served it.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Recover a model-request failure after its failed step has closed.', }, { diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f937067588..2a9a2468f3 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -60,7 +60,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, immutable prior failures, and the immutable retry policy of the adapter registration that served the request after the failed step closes; the policy is absent if no final adapter served it. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 4324deca8d..fcf4dfa846 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -7,9 +7,9 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, llmRetryPolicyOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' @@ -33,6 +33,7 @@ class TerminalModelRequestFailure extends Error { constructor( readonly requestError: RequestError, readonly failure: LlmFailure, + readonly retryPolicy: ResolvedRetryPolicy | undefined, ) { super(failure.message, { cause: requestError }) this.name = 'TerminalModelRequestFailure' @@ -442,14 +443,18 @@ async function runTurn( let stepOutcome: | { hadToolCalls: boolean; finish: FinishReason } - | { requestError: RequestError; failure: LlmFailure } + | { requestError: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } | { error: RequestError } try { stepOutcome = await runStep( ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) } catch (error: unknown) { if (error instanceof TerminalModelRequestFailure) { - stepOutcome = { requestError: error.requestError, failure: error.failure } + stepOutcome = { + requestError: error.requestError, + failure: error.failure, + retryPolicy: error.retryPolicy, + } } else { stepOutcome = { error: toError(error) } } @@ -470,7 +475,7 @@ async function runTurn( try { recoveryDecision = await events.waterfall( 'agent/request-error', turn, step, stepOutcome.requestError, - stepOutcome.failure, requestFailureHistory, signal, + stepOutcome.failure, requestFailureHistory, stepOutcome.retryPolicy, signal, () => Promise.resolve(defaultDecision), ) } catch (recoveryError: unknown) { @@ -714,14 +719,18 @@ async function runStep( } } catch (error: unknown) { const failure = llmFailureOf(stream, error) - if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) + if (failure !== undefined && error instanceof Error) { + throw new TerminalModelRequestFailure(error, failure, llmRetryPolicyOf(stream)) + } throw error } interruptionCheckpoint(signal) // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) - if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure) + if (stepError) { + throw new TerminalModelRequestFailure(stepError.error, stepError.failure, llmRetryPolicyOf(stream)) + } const recordAssistantMessage = ( assembledContent: ContentBlock[], diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 1fc44e3431..494c2f95a9 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -269,10 +269,11 @@ describe('agent post-step and request-error lifecycle', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' }) const attempts: number[] = [] - ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => { + ctx.on('agent/request-error', async (subject, turn, step, error, facts, history, retryPolicy) => { expect(subject).toBe(agent) expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(retryPolicy).toMatchObject({ mode: 'normal', maxRetries: 2 }) attempts.push(history.length) subject.session.append('user/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], @@ -301,7 +302,9 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let recoveries = 0 install(ctx) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { recoveries += 1 return next() }) @@ -332,7 +335,9 @@ describe('agent post-step and request-error lifecycle', () => { }) const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { recoveries += 1 return next() }) @@ -365,7 +370,9 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { recoveries += 1 return next() }) @@ -393,7 +400,9 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { recoveries += 1 return next() }) @@ -412,7 +421,9 @@ describe('agent post-step and request-error lifecycle', () => { const ctx = await harness(makeAdapter(original)) const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let seen: Error | undefined - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, error, _failure, _history, _retryPolicy, _signal, next, + ) => { seen = error return next() }) @@ -431,7 +442,9 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' }) let seenError: Error | undefined let seenFailure: LlmFailure | undefined - ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, error, failure, _history, _retryPolicy, _signal, next, + ) => { seenError = error seenFailure = failure return next() @@ -462,7 +475,7 @@ describe('agent post-step and request-error lifecycle', () => { let seenFailure: LlmFailure | undefined let seenHistory: readonly LlmFailure[] | undefined ctx.on('agent/request-error', async ( - _agent, _turn, _step, error, failure, history, _signal, next, + _agent, _turn, _step, error, failure, history, _retryPolicy, _signal, next, ) => { seenError = error seenFailure = failure @@ -506,13 +519,18 @@ describe('agent post-step and request-error lifecycle', () => { const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness() const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' }) let seen = '' - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { + let sawServingPolicy = false + ctx.on('agent/request-error', async ( + _agent, _turn, _step, error, _failure, _history, retryPolicy, _signal, next, + ) => { seen = error.code ?? '' + sawServingPolicy = retryPolicy !== undefined return next() }) send(agent) await waitForIdle(ctx, agent) expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER') + expect(sawServingPolicy).toBe(scenario === 'iterator') } }) @@ -522,7 +540,7 @@ describe('agent post-step and request-error lifecycle', () => { const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' }) const cappedHistories: string[][] = [] cappedCtx.on('agent/request-error', async ( - _agent, _turn, _step, _error, _failure, history, _signal, next, + _agent, _turn, _step, _error, _failure, history, _retryPolicy, _signal, next, ) => { const codes = history.map(entry => entry.code) cappedHistories.push(codes) @@ -547,7 +565,7 @@ describe('agent post-step and request-error lifecycle', () => { const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' }) const resetHistories: { step: number; codes: string[] }[] = [] resetCtx.on('agent/request-error', async ( - _agent, _turn, step, _error, _failure, history, _signal, next, + _agent, _turn, step, _error, _failure, history, _retryPolicy, _signal, next, ) => { resetHistories.push({ step, codes: history.map(entry => entry.code) }) return resetHistories.length === 1 ? { action: 'retry' } : next() @@ -578,7 +596,9 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' }) let entered!: () => void const recoveryEntered = new Promise((resolve) => { entered = resolve }) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, signal, + ) => { entered() await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 0f442e3f88..0efe140b22 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -456,11 +456,13 @@ declare module 'cordis' { * @param error - the original model-request failure. * @param failure - serializable facts normalized at the final adapter boundary. * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. + * @param retryPolicy - immutable policy of the adapter registration that served + * the failed request, or `undefined` if no final adapter served it. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise /** * Override whether the turn continues. The default continues after tool * calls or steering and stops otherwise; a continue reason becomes steering. diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index ca0841165b..c54753c301 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -51,7 +51,7 @@ describe('scoped-dispatch invariants', () => { 'agent/post-step': [agent, 1, 1, signal], 'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })], 'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)], - 'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], signal, () => Promise.resolve({ action: 'fail' })], + 'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], undefined, signal, () => Promise.resolve({ action: 'fail' })], 'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])], 'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)], 'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })], diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 711666b8a0..72d7ac6831 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -2,7 +2,7 @@ Function plugin that applies exact-provider retry policy on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm`. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it. Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 04f1b0996a..7e9c2d56e6 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -37,13 +37,13 @@ declare module '@deepseek-ai/dsh-session' { } export const name = 'llm-retry' -export const inject = ['agents', 'llm'] +export const inject = ['agents'] /** This policy executor has no config; providers own `retryPolicy`. */ -export type Config = Readonly> +export type Config = Readonly> /** Runtime schema for {@link Config}. */ -export const Config: z = z.object({}) +export const Config = z.object({}) as unknown as z function validateConfig(config: Config): void { const [key] = Object.keys(config) @@ -170,6 +170,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna _error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], + policy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise, ) => { @@ -177,15 +178,15 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) - // Bind policy to the header in force when this step closed. Downstream - // recovery may append later state before an always fallback runs. + if (policy === undefined) return next() + // The call-local policy belongs to the registration that served this + // failure. Recover only the durable provider identity from the header; + // downstream recovery may append later state before an always fallback. const provider = providerForClosedStep(agent.session.events, turn, step) /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ if (provider === undefined) { throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) } - const policy = ctx.llm.providerRetryPolicy(provider) - if (policy.mode === 'always') { const downstream = await downstreamUntilAbort( next, diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 56f785f38c..232f4b4459 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' import LlmService, { CallId, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' @@ -79,7 +79,7 @@ async function harness( policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, internals: retry.RetryInternals = {}, -): Promise<{ ctx: Context; retryFiber: Fiber }> { +): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -92,8 +92,8 @@ async function harness( retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock', 'other'], adapter) - return { ctx, retryFiber } + const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter) + return { ctx, retryFiber, disposeAdapter } } function normalConfig( @@ -413,6 +413,28 @@ describe('provider-routed retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) + it('delegates when no final adapter served the failed request', async () => { + const adapter = new ScriptedAdapter([textResponse('must not run')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + mounted.disposeAdapter() + const agent = context.agentLoop.create(SessionId('retry-no-serving-policy'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup([{ type: 'text', text: 'missing route' }]) + await idle + + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { code: 'NO_ADAPTER' } } }, + }) + }) + it('selects policy by the failed request provider', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ @@ -481,6 +503,64 @@ describe('provider-routed retry policy', () => { expect(adapter.requests.map(request => request.provider)).toEqual(['other', 'other']) }) + it.each(['thrown', 'in-band'] as const)( + 'uses the serving registration policy when an in-flight route is replaced after a %s failure', + async (failureKind) => { + vi.useFakeTimers() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const oldAdapter = new ScriptedAdapter([(async function * (): AsyncGenerator { + entered.resolve(undefined) + await release.promise + if (failureKind === 'thrown') { + throw new LlmError('old route auth failed', 'AUTH') + } + yield { + type: 'finish', + reason: { + kind: 'error', + failure: { message: 'old route auth failed', code: 'AUTH' }, + }, + } + })()]) + const mounted = await harness(oldAdapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) }) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-serving-registration'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + agent.followup([{ type: 'text', text: 'replace while in flight' }]) + await entered.promise + + mounted.disposeAdapter() + const replacement = new ScriptedAdapter([textResponse('replacement recovered')]) + replacement.configureRetryPolicies({ mock: normalConfig({ maxRetries: 0 }) }) + context.llm.registerAdapter(['mock'], replacement) + release.resolve(undefined) + + expect((await scheduled).data).toMatchObject({ + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + }) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(oldAdapter.requests).toHaveLength(1) + expect(replacement.requests).toHaveLength(1) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'replacement recovered' }], + }) + }, + ) + it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ @@ -719,7 +799,9 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers() let invokeCaptured: (() => Promise) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { return new Promise((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -728,7 +810,9 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + context.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { downstreamCalls += 1 return next() }) @@ -782,7 +866,9 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { agent.cancel({ kind: 'user' }) return next() }) @@ -823,14 +909,16 @@ describe('provider-routed retry policy', () => { }) it('rejects retry policy configured on the executor instead of a provider', () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() expect(() => { - retry.apply(new Context(), { retryPolicy: { mode: 'always' } }) + retry.apply(new Context(), { retryPolicy: { mode: 'always' } } as unknown as retry.Config) }).toThrow(/retryPolicy belongs under each provider/) }) it('rejects unknown executor config', () => { expect(() => { - retry.apply(new Context(), { retryPolciy: {} }) + retry.apply(new Context(), { retryPolciy: {} } as unknown as retry.Config) }).toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 74dafa3e48..6a855f6004 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -15,7 +15,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 390282327d..87e783dcea 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -6,9 +6,15 @@ import { HarnessError } from './error.ts' import type { LlmFailure, StreamChunk } from './types.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' -/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */ -export type AdapterFailureScope = WeakMap +/** Call-local facts captured when one model call enters its final adapter boundary. */ +export interface AdapterFailureScope { + /** Errors and normalized facts proven to originate in this call's final adapter boundary. */ + readonly failures: WeakMap + /** Immutable policy of the exact adapter registration selected for this call. */ + retryPolicy?: ResolvedRetryPolicy +} /** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ const adapterFailureScopes = new WeakMap, AdapterFailureScope>() @@ -52,7 +58,7 @@ export function markLlmAdapterFailure( message: errorMessage(error), code: harnessErrorCode(error), }) - failures.set(error, failure) + failures.failures.set(error, failure) return error } @@ -124,7 +130,7 @@ export function isLlmAdapterFailure( value: unknown, ): value is Error & { code?: string } { const failures = adapterFailureScopes.get(stream) - return value instanceof Error && failures !== undefined && failures.has(value) + return value instanceof Error && failures !== undefined && failures.failures.has(value) } /** @@ -139,5 +145,18 @@ export function llmFailureOf( value: unknown, ): LlmFailure | undefined { const failures = adapterFailureScopes.get(stream) - return value instanceof Error ? failures?.get(value) : undefined + return value instanceof Error ? failures?.failures.get(value) : undefined +} + +/** + * Read the retry policy of the exact registration selected at this call's + * final adapter boundary. The policy remains available after that registration + * is disposed or replaced; absence means no final adapter served the call. + * @param stream - the exact stream returned by the model call. + * @returns the immutable serving-registration policy, or `undefined`. + */ +export function llmRetryPolicyOf( + stream: AsyncIterable, +): ResolvedRetryPolicy | undefined { + return adapterFailureScopes.get(stream)?.retryPolicy } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 773b9a5ca1..6b714a7074 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -33,7 +33,7 @@ export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' -export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts' +export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -337,7 +337,9 @@ export class LlmService extends Service { ): AsyncGenerator { let iterator: AsyncIterator try { - const adapter = this.registration(options.provider).adapter + const registration = this.registration(options.provider) + failures.retryPolicy = registration.retryPolicy + const adapter = registration.adapter const stream = adapter.stream(this.forAdapter(options, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { @@ -386,7 +388,7 @@ export class LlmService extends Service { * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - const failures: AdapterFailureScope = new WeakMap() + const failures: AdapterFailureScope = { failures: new WeakMap() } const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) return bindAdapterFailureScope(stream, failures) } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 52c51852ba..5631191b22 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -10,6 +10,7 @@ import LlmService, { LlmAdapter, LlmError, llmFailureOf, + llmRetryPolicyOf, ProviderRequestId, resolveRetryPolicy, StreamChunk, @@ -181,6 +182,51 @@ describe('LlmService', () => { ) }) + it('keeps the serving registration policy on an in-flight call after route replacement', async () => { + const oldPolicy = resolveRetryPolicy({ mode: 'always' }, 'old retryPolicy') + const newPolicy = resolveRetryPolicy({ mode: 'normal', maxRetries: 0 }, 'new retryPolicy') + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const failure = new LlmError('old route failed', 'AUTH') + const oldAdapter = new class extends LlmAdapter { + override providerRetryPolicy(): typeof oldPolicy { + return oldPolicy + } + + async * stream(_options: GenerateOptions): AsyncIterable { + entered.resolve(undefined) + await release.promise + throw failure + } + }() + const newAdapter = new class extends ScriptedAdapter { + override providerRetryPolicy(): typeof newPolicy { + return newPolicy + } + }(SCRIPT) + const ctx = new Context() + await ctx.plugin(LlmService) + const disposeOld = ctx.llm.registerAdapter(['route'], oldAdapter) + const stream = ctx.llm.stream({ provider: 'route', model: 'model', messages: [] }) + const outcome = (async (): Promise => { + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + return error + } + return undefined + })() + await entered.promise + + disposeOld() + ctx.llm.registerAdapter(['route'], newAdapter) + release.resolve(undefined) + + expect(await outcome).toBe(failure) + expect(llmRetryPolicyOf(stream)).toBe(oldPolicy) + expect(ctx.llm.providerRetryPolicy('route')).toBe(newPolicy) + }) + it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -195,6 +241,7 @@ describe('LlmService', () => { expect((caught as LlmError).code).toBe('NO_ADAPTER') expect((caught as LlmError).message).toContain('no adapter registered') expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(llmRetryPolicyOf(stream)).toBeUndefined() }) it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 26ad904d30..6ba7a719d7 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -184,6 +184,7 @@ export class PlanModeService extends Service { _error, _failure, _priorFailures, + _retryPolicy, _signal, next, ) => { diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index e195ed54b2..1500554ad7 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -138,7 +138,9 @@ describe('plan mode through the agent loop', () => { const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) const recoveryEntered = Promise.withResolvers() const releaseRecovery = Promise.withResolvers() - ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => { + ctx.on('agent/request-error', async ( + subject, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, next, + ) => { if (subject !== agent) return next() recoveryEntered.resolve(true) await releaseRecovery.promise diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index a7f7743497..786d1425c2 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -83,6 +83,7 @@ function recoveryBoundary( new Error('request failed'), { message: 'request failed', code: 'SERVER' }, [], + undefined, new AbortController().signal, () => Promise.resolve(decision), ) @@ -945,7 +946,9 @@ describe('HMR disposal', () => { const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery') const recoveryEntered = Promise.withResolvers() const releaseRecovery = Promise.withResolvers() - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => { + ctx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, _history, _retryPolicy, _signal, _next, + ) => { recoveryEntered.resolve(true) await releaseRecovery.promise return { action: 'retry' } From 38ce422f71d2d27ae49d107f23009705f16fd93a Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:23:55 +0800 Subject: [PATCH 07/41] fix(llm): drain delegated retry recovery --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 4 +- .../2026-07-24-provider-retry-policies.zh.md | 4 +- packages/examples/acp-demo/README.md | 1 - packages/llm/llm-retry/README.md | 6 +- packages/llm/llm-retry/src/index.ts | 79 ++++++++++--------- packages/llm/llm-retry/tests/retry.spec.ts | 60 ++++++++++---- 7 files changed, 94 insertions(+), 64 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 000b11d7f0..6d41bf0ab7 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: 0b9456eb1563bfcbfa06d93403fd68124ed1f707 -2026-07-24-provider-retry-policies.zh.md: d3ef90ec739eec7300b7d0bb526cb5de958ed93f +2026-07-24-provider-retry-policies.md: 3799dbee9658c883f4084ac9c792d4552cc8e2fb +2026-07-24-provider-retry-policies.zh.md: 8024ea707343686d2d1b1351cd7c81ecb8767b85 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index 0b9456eb15..3799dbee96 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -36,7 +36,7 @@ providers: The listener reads the provider from the durable `request/header` in force when the failed step closed, excluding later recovery mutations, but never re-resolves policy from the mutable provider registry. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. -Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. Success, turn cancellation, and plugin disposal are the only termination paths. +Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. The retry listener owns and drains delegated recovery before cancellation or plugin disposal can finish, then applies the abort instead of a late downstream decision. Success, turn cancellation, and plugin disposal are the only termination paths. Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff. @@ -56,7 +56,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and real-Loader composition tests select policies from the failed request's serving registration, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation interrupts stalled downstream recovery, and prove cancellation and disposal stop active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; ACP and TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and real-Loader composition tests select policies from the failed request's serving registration, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index d3ef90ec73..8024ea7073 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -36,7 +36,7 @@ providers: 监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 -always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。成功、轮次取消和插件 dispose(资源释放)是仅有的终止路径。 +always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。重试监听器会持有并排空已委托的恢复,轮次取消或插件 dispose(资源释放)只能在其结束后完成;随后监听器会应用取消,而不会采用迟到的下游决定。成功、轮次取消和插件 dispose 是仅有的终止路径。 两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 @@ -56,7 +56,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与真实 Loader 组合测试根据失败请求实际使用的注册项选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消会中断停滞的下游恢复,并证明取消与 dispose 会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;ACP 与 TUI 测试会分别渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与真实 Loader 组合测试根据失败请求实际使用的注册项选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会在达到静止状态前排空已委托的恢复,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index ca101fb675..c5803e4f1b 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -33,7 +33,6 @@ The app does not install commands, user interaction, session navigation, configu | `toolBash` | owner defaults | Model-facing bash tool config. | | `toolTasks` | owner defaults | Generic background-task control config, or `false`. | | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | -| `llmRetry` | owner defaults | Provider-owned normal or unbounded model-request retry policy. | The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. Snapshot overlays replace only nondeterministic providers or policy values. diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 72d7ac6831..452b395d4d 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -2,11 +2,11 @@ Function plugin that applies exact-provider retry policy on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it after active delegated recovery reaches quiescence. Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, failure, and scheduled delay. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort the wait; disposal drains active backoffs, and a callback captured before disposal fails closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, failure, and scheduled delay. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, has a unique step record and correct provider-policy retry number, and carries a valid mode-specific budget and bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. @@ -46,5 +46,5 @@ The reconstructed request preserves the prior prefix and is eligible for provide - **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. - **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls. -- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that never settles also prevents the fallback from running. +- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing. - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 7e9c2d56e6..7de5d4f06b 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -63,32 +63,15 @@ export interface RetryInternals { type DownstreamOutcome = | { readonly type: 'decision'; readonly decision: RequestErrorDecision } | { readonly type: 'error'; readonly error: unknown } - | { readonly type: 'aborted' } -function downstreamUntilAbort( +async function settleDownstream( next: () => Promise, - signal: AbortSignal, ): Promise { - if (signal.aborted) return Promise.resolve({ type: 'aborted' }) - return new Promise((resolve) => { - const finish = (outcome: DownstreamOutcome): void => { - signal.removeEventListener('abort', onAbort) - resolve(outcome) - } - const onAbort = (): void => { finish({ type: 'aborted' }) } - signal.addEventListener('abort', onAbort, { once: true }) - let downstream: Promise - try { - downstream = next() - } catch (error: unknown) { - finish({ type: 'error', error }) - return - } - void downstream.then( - (decision) => { finish({ type: 'decision', decision }) }, - (error: unknown) => { finish({ type: 'error', error }) }, - ) - }) + try { + return { type: 'decision', decision: await next() } + } catch (error: unknown) { + return { type: 'error', error } + } } function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number { @@ -125,6 +108,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const lifetime = new AbortController() const active = new Set>() + function track(operation: Promise): Promise { + const tracked = operation.finally(() => active.delete(tracked)) + active.add(tracked) + return tracked + } + async function backoff( agent: Agent, turn: number, @@ -163,7 +152,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna return { action: 'retry' } } - const disposeListener = ctx.on('agent/request-error', async ( + async function recover( agent: Agent, turn: number, step: number, @@ -173,11 +162,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna policy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise, - ) => { - // A waterfall may have captured this callback before its registration was - // removed. Lifetime cancellation must prevent that stale callback from - // entering a downstream policy after disposal. - if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) + ): Promise { if (policy === undefined) return next() // The call-local policy belongs to the registration that served this // failure. Recover only the durable provider identity from the header; @@ -188,11 +173,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) } if (policy.mode === 'always') { - const downstream = await downstreamUntilAbort( - next, - AbortSignal.any([signal, lifetime.signal]), - ) - if (downstream.type === 'aborted') return { action: 'fail' } + if (signal.aborted || lifetime.signal.aborted) return { action: 'fail' } + const fusedSignal = AbortSignal.any([signal, lifetime.signal]) + // The loop and plugin lifetime stay open until delegated recovery settles. + // An abort then wins before the decision or fallback can mutate later state. + const downstream = await settleDownstream(next) + if (fusedSignal.aborted) return { action: 'fail' } if (downstream.type === 'error') { ctx.logger.warn( `llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, @@ -232,15 +218,30 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna delayMs = localDelay(policy, retry, random) } - const tracked = backoff(agent, turn, step, failure, provider, policy, retry, delayMs, signal) - .finally(() => active.delete(tracked)) - active.add(tracked) - return tracked + return backoff(agent, turn, step, failure, provider, policy, retry, delayMs, signal) + } + + const disposeListener = ctx.on('agent/request-error', ( + agent: Agent, + turn: number, + step: number, + error: RequestError, + failure: LlmFailure, + priorFailures: readonly LlmFailure[], + policy: ResolvedRetryPolicy | undefined, + signal: AbortSignal, + next: () => Promise, + ) => { + // A waterfall may have captured this callback before its registration was + // removed. Lifetime cancellation must prevent that stale callback from + // entering a downstream policy after disposal. + if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) + return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next)) }) ctx.effect(() => async () => { disposeListener() lifetime.abort(new Error('llm-retry plugin disposed')) await Promise.allSettled([...active]) - }, 'llm-retry: abort and drain backoffs') + }, 'llm-retry: abort and drain active recovery') } diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 232f4b4459..2533eb045f 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -706,61 +706,80 @@ describe('provider-routed retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('does not make plugin disposal wait for a delegated recovery policy', async () => { + it('drains delegated recovery before completing plugin disposal', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx - const downstream = Promise.withResolvers() + const release = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', () => { + const order: string[] = [] + context.on('agent/request-error', async () => { entered.resolve(undefined) - return downstream.promise + await release.promise + order.push('downstream') + return { action: 'retry' } }) const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), { provider: 'mock', model: 'mock', }) - const idle = waitForIdle(context, agent) + const idle = waitForIdle(context, agent).then(() => { order.push('idle') }) agent.followup([{ type: 'text', text: 'go' }]) await entered.promise - const disposing = mounted.retryFiber.dispose() + const disposing = mounted.retryFiber.dispose().then(() => { order.push('disposed') }) let timer: ReturnType | undefined const outcome = await Promise.race([ disposing.then(() => 'disposed' as const), new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), ]) if (timer !== undefined) clearTimeout(timer) - downstream.resolve({ action: 'fail' }) + expect(outcome).toBe('blocked') + + release.resolve(undefined) await disposing await idle - expect(outcome).toBe('disposed') + expect(order[0]).toBe('downstream') + expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) }) - it('lets turn cancellation interrupt a delegated recovery policy', async () => { + it('drains delegated recovery before turn cancellation reaches idle', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', () => { + const order: string[] = [] + context.on('agent/request-error', async () => { entered.resolve(undefined) - return downstream.promise + const decision = await downstream.promise + order.push('downstream') + return decision }) const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), { provider: 'mock', model: 'mock', }) - const idle = waitForIdle(context, agent) + const idle = waitForIdle(context, agent).then(() => { order.push('idle') }) agent.followup([{ type: 'text', text: 'go' }]) await entered.promise agent.cancel({ kind: 'user' }) - await idle - downstream.resolve({ action: 'fail' }) + let timer: ReturnType | undefined + const outcome = await Promise.race([ + idle.then(() => 'idle' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + expect(outcome).toBe('blocked') + downstream.resolve({ action: 'retry' }) + await idle + + expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', @@ -773,8 +792,10 @@ describe('provider-routed retry policy', () => { const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() context.on('agent/request-error', (agent) => { agent.cancel({ kind: 'user' }) + entered.resolve(undefined) return downstream.promise }) const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { @@ -784,8 +805,17 @@ describe('provider-routed retry policy', () => { const idle = waitForIdle(context, agent) agent.followup([{ type: 'text', text: 'go' }]) + await entered.promise + let timer: ReturnType | undefined + const outcome = await Promise.race([ + idle.then(() => 'idle' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + expect(outcome).toBe('blocked') + + downstream.resolve({ action: 'retry' }) await idle - downstream.resolve({ action: 'fail' }) expect(adapter.requests).toHaveLength(1) expect(agent.session.events.at(-1)).toMatchObject({ From efc725b7e4b44a7fa2198db8fbfea90b896ada61 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:38:58 +0800 Subject: [PATCH 08/41] fix(llm): isolate retry policy histories --- ...2026-06-21-bounded-llm-request-recovery.md | 4 +- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 6 +- .../2026-07-24-provider-retry-policies.zh.md | 6 +- docs/config-catalog.md | 10 +- docs/persistence-catalog.md | 4 +- .../headless-agent/retry.cordis.snapshot.yml | 12 ++ .../tests/fixtures/retry-snapshot-backend.mjs | 53 +++++++++ .../headless-agent/tests/headless.snapshot.ts | 42 +++++++ .../tests/snapshots/provider-retry/input.json | 8 ++ .../provider-retry/stream-json.expected.jsonl | 17 +++ packages/examples/acp-demo/src/index.ts | 4 + .../examples/acp-demo/tests/acp-agent.spec.ts | 10 ++ .../examples/acp-demo/tests/built-bin.e2e.ts | 14 +++ .../examples/agent-spine-demo/src/index.ts | 5 + .../agent-spine-demo/tests/agent-core.spec.ts | 8 ++ packages/examples/cli-demo/src/index.ts | 4 + .../examples/cli-demo/tests/built-bin.e2e.ts | 33 ++++++ packages/examples/cli-demo/tests/cli.spec.ts | 10 ++ packages/examples/tui-demo/src/index.ts | 4 + .../examples/tui-demo/tests/tui-agent.spec.ts | 12 +- packages/llm/llm-retry/README.md | 4 +- packages/llm/llm-retry/src/index.ts | 11 +- packages/llm/llm-retry/src/invariant.ts | 26 ++++- packages/llm/llm-retry/src/policy-key.ts | 100 +++++++++++++++++ .../llm/llm-retry/tests/invariant.spec.ts | 100 ++++++++++++++++- .../llm/llm-retry/tests/persistence.spec.ts | 1 + .../llm/llm-retry/tests/policy-key.spec.ts | 71 ++++++++++++ packages/llm/llm-retry/tests/retry.spec.ts | 105 ++++++++++++++++++ packages/ui/tui/tests/tui.snapshot.ts | 2 + packages/ui/tui/tests/tui.spec.ts | 4 + 31 files changed, 666 insertions(+), 28 deletions(-) create mode 100644 examples/headless-agent/retry.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs create mode 100644 examples/headless-agent/tests/snapshots/provider-retry/input.json create mode 100644 examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl create mode 100644 packages/llm/llm-retry/src/policy-key.ts create mode 100644 packages/llm/llm-retry/tests/policy-key.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 1e0ef18f9c..268ade3cfc 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -56,9 +56,9 @@ The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. -The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. +The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 6d41bf0ab7..792625b86a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: 3799dbee9658c883f4084ac9c792d4552cc8e2fb -2026-07-24-provider-retry-policies.zh.md: 8024ea707343686d2d1b1351cd7c81ecb8767b85 +2026-07-24-provider-retry-policies.md: 06d21d10d31cc277e998ff6006bb01f10fe0b6b9 +2026-07-24-provider-retry-policies.zh.md: d8351bd94d444b116483c81dc70bf0f6768dc37d diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index 3799dbee96..06d21d10d3 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -34,13 +34,13 @@ providers: jitterRatio: 0.2 ``` -The listener reads the provider from the durable `request/header` in force when the failed step closed, excluding later recovery mutations, but never re-resolves policy from the mutable provider registry. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. +The listener reads the provider from the durable `request/header` in force when the failed step closed, excluding later recovery mutations, but never re-resolves policy from the mutable provider registry. It derives a canonical key from every field of the resolved serving policy, sorting `retryableCodes` because eligibility uses set membership, and continues retry history only for the same provider and key. Replacing a route with different limits, code membership, or backoff therefore starts a new count and initial delay even when the mode is unchanged. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries` and otherwise delegates. Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. The retry listener owns and drains delegated recovery before cancellation or plugin disposal can finish, then applies the abort instead of a late downstream decision. Success, turn cancellation, and plugin disposal are the only termination paths. Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff. -Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface. +Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface. ## Alternatives considered @@ -56,7 +56,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and real-Loader composition tests select policies from the failed request's serving registration, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and plugin-validation tests select policies from the failed request's serving registration, reject top-level `llmRetry` at the spine, CLI, TUI, and ACP schemas, separate different same-mode policies while preserving histories across reordered code sets, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Published Loader fixtures reject the invalid app-level key in CLI and ACP and the invalid bundle-level key when loading the spine directly. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests validate the canonical policy tuple, bind its provider to the request header, bind its failure code and delay to the encoded policy, and bind its retry number to the active provider policy key; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 8024ea7073..d8351bd94d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -34,13 +34,13 @@ providers: jitterRatio: 0.2 ``` -监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 +监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。它会根据已解析实际服务策略的所有字段生成规范键;由于错误资格按集合成员判断,生成时会对 `retryableCodes` 排序。重试历史只会对同一提供方和同一规范键延续。因此,即使模式未变,只要路由替换后的次数上限、错误代码成员或退避不同,重试计数与初始延迟都会重新开始。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;其他情况委托后续处理。 always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。重试监听器会持有并排空已委托的恢复,轮次取消或插件 dispose(资源释放)只能在其结束后完成;随后监听器会应用取消,而不会采用迟到的下游决定。成功、轮次取消和插件 dispose 是仅有的终止路径。 两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 -每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。 +每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、已解析策略的规范键、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。 ## 曾考虑的替代方案 @@ -56,7 +56,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与真实 Loader 组合测试根据失败请求实际使用的注册项选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会在达到静止状态前排空已委托的恢复,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与插件校验测试根据失败请求实际使用的注册项选择策略、在主干、CLI、TUI 与 ACP schema 拒绝顶层 `llmRetry`、分离模式相同但策略不同的替换路由历史,同时在错误代码集合仅顺序不同时延续历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。发布版 Loader fixture(测试前置数据)会在 CLI 与 ACP 中拒绝无效的应用级配置键,并在直接加载主干时拒绝无效的 bundle 级配置键。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会校验规范策略元组、将事件中的提供方绑定到请求头、将失败代码与延迟绑定到编码后的策略,并将重试编号绑定到活跃的提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bc2028abac..1aeee0570e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -73,6 +73,8 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ goals?: agentCore.GoalConfig | false + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } ``` @@ -160,6 +162,8 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false + /** Invalid at bundle level; configure `retryPolicy` under each provider. */ + llmRetry?: never } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -261,6 +265,8 @@ export interface Config { toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } ``` @@ -696,7 +702,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:43`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -1794,6 +1800,8 @@ export interface Config { resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6f74a5ee36..8cd8c78110 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -277,6 +277,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- step: number provider: string mode: 'normal' + policyKey: string retry: number maxRetries: number delayMs: number @@ -286,13 +287,14 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- step: number provider: string mode: 'always' + policyKey: string retry: number delayMs: number failure: LlmFailure } ``` -Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:19`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` diff --git a/examples/headless-agent/retry.cordis.snapshot.yml b/examples/headless-agent/retry.cordis.snapshot.yml new file mode 100644 index 0000000000..b8fc79d17f --- /dev/null +++ b/examples/headless-agent/retry.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Keyless provider-retry composition for the headless stream-json snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: retry-snapshot-backend + name: './tests/fixtures/retry-snapshot-backend.mjs' diff --git a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs new file mode 100644 index 0000000000..28dc4f5742 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs @@ -0,0 +1,53 @@ +/** Deterministic provider adapter for the headless retry-policy snapshot. */ + +import { + LlmAdapter, + LlmError, + resolveRetryPolicy, +} from '@deepseek-ai/dsh-llm' + +class RetrySnapshotAdapter extends LlmAdapter { + requests = 0 + firstMessages + policy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'retry-snapshot-backend.retryPolicy') + + providerRetryPolicy() { + return this.policy + } + + async * stream(options) { + const messages = JSON.stringify(options.messages) + this.requests++ + if (this.requests === 1) { + this.firstMessages = messages + throw new LlmError('snapshot transient failure', 'RATE_LIMIT', { status: 429 }) + } + if (this.requests === 2 && messages !== this.firstMessages) { + throw new Error('retry snapshot changed the model-visible messages') + } + const text = 'RETRY_OK' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Cordis plugin name. */ +export const name = 'retry-snapshot-backend' +/** Required LLM registry service. */ +export const inject = ['llm'] + +/** + * Register the deterministic provider adapter. + * @param {import('cordis').Context} ctx - plugin context carrying the LLM service. + */ +export function apply(ctx) { + ctx.llm.registerAdapter(['deepseek'], new RetrySnapshotAdapter()) +} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..61405d98ef 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -24,6 +24,8 @@ const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl') const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url)) const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) +const retryScenarioDir = join(snapshotsDir, 'provider-retry') +const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -124,6 +126,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('retries a transient provider failure through the one-shot app', async () => { + const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') + const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'provider retry headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-provider-retry-', + binScript, + configPath: retryConfigPath, + binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const retries = records.filter(record => record.type === 'llm/retry') + expect(retries).toHaveLength(1) + expect(retries[0]?.data).toMatchObject({ + provider: 'deepseek', + mode: 'normal', + policyKey: '["normal",1,["RATE_LIMIT"],1,1,0]', + retry: 1, + maxRetries: 1, + delayMs: 1, + failure: { message: 'snapshot transient failure', code: 'RATE_LIMIT', status: 429 }, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/examples/headless-agent/tests/snapshots/provider-retry/input.json b/examples/headless-agent/tests/snapshots/provider-retry/input.json new file mode 100644 index 0000000000..2dc62032a6 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "retry the transient provider failure" + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl new file mode 100644 index 0000000000..a686a03ec3 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -0,0 +1,17 @@ +{"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":"retry the transient provider failure"}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"retry the transient provider failure","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":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":6,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":7,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"RETRY_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":15,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"RETRY_OK","reason":{"kind":"completed"},"usage":{"inputTokens":4,"outputTokens":2}} diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index d7161c589f..c4d52d0053 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -67,6 +67,8 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ goals?: agentCore.GoalConfig | false + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } // Each front door owns a complete, directly readable config schema; extracting @@ -92,6 +94,8 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), + // Provider retryPolicy makes a top-level llmRetry invalid. + llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index e932c7fb17..7c32da8cfa 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -76,6 +76,16 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } describe('dsh-acp-demo composition', () => { + it('rejects app-level llmRetry config through plugin validation', async () => { + const ctx = new Context() + await expect(ctx.plugin(acpAgent, { + provider: 'mock', + model: 'mock', + workspaceContext: false, + llmRetry: { maxTransientRetries: 2 }, + } as never)).rejects.toThrow(/llmRetry/) + }) + it('brings up the spine + persistence + the ACP bridge', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..dd7139815f 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -207,6 +207,20 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n expect(code).not.toBe(0) expect(stderr).toContain('config file not found') }, 30_000) + + it('rejects legacy app-level llmRetry through the published Loader path', async () => { + consumer = await makeConsumer() + const configPath = join(consumer, 'cordis.yml') + const config = await readFile(configPath, 'utf8') + await writeFile(configPath, config.replace( + ' workspaceContext: false', + ' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2', + )) + + const { code, stderr } = await runBinExpectingExit('./cordis.yml', consumer) + expect(code).not.toBe(0) + expect(stderr).toContain('llmRetry') + }, 30_000) }) /** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index d2a65844ea..397966813f 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -112,6 +112,8 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false + /** Invalid at bundle level; configure `retryPolicy` under each provider. */ + llmRetry?: never } /** The skill config schema exported for app packages that forward `skills`. */ @@ -152,6 +154,9 @@ export const Config = z.intersect([ toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), + // Schemastery preserves unknown object properties. A top-level llmRetry is + // known-but-impossible because provider retryPolicy owns this configuration. + llmRetry: z.never(), }) as unknown as z>, ]) as unknown as z diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 2051aee4b8..33e8ddc6cd 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -594,6 +594,14 @@ describe('dsh-agent-spine-demo bundle', () => { expect(agentCore.name).toBe('agent-spine-demo') }) + it('rejects bundle-level llmRetry config through plugin validation', async () => { + const ctx = new Context() + await expect(ctx.plugin(agentCore, { + workspaceContext: false, + llmRetry: { maxTransientRetries: 2 }, + } as never)).rejects.toThrow(/llmRetry/) + }) + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { // A default export would make `unwrapExports` collapse this inject-less namespace and silently // drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard. diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index bfec7c9a4f..e9bbb0a237 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -52,6 +52,8 @@ export interface Config { toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } // Each front door keeps a complete Loader schema so its deployment contract is @@ -73,6 +75,8 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), + // Provider retryPolicy makes a top-level llmRetry invalid. + llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..ddc07a93a1 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -192,6 +192,39 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { } }, 30_000) + it('rejects legacy app-level llmRetry through the published Loader path', async () => { + consumer = await makeConsumer() + const configPath = join(consumer, 'cordis.yml') + const config = await readFile(configPath, 'utf8') + await writeFile(configPath, config.replace( + ' workspaceContext: false', + ' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2', + )) + + const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task']) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('llmRetry') + }, 30_000) + + it('rejects legacy bundle-level llmRetry when the published spine is loaded directly', async () => { + consumer = await makeConsumer() + await writeFile(join(consumer, 'cordis.yml'), [ + '- id: spine', + " name: '@deepseek-ai/dsh-agent-spine-demo'", + ' config:', + ' workspaceContext: false', + ' llmRetry:', + ' maxTransientRetries: 2', + '', + ].join('\n')) + + const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task']) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('llmRetry') + }, 30_000) + describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => { it.each([ ['SIGINT', 130], diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index bf681af2f8..786662caba 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -173,6 +173,16 @@ afterEach(async () => { }) describe('parseCliArgs', () => { + it('rejects app-level llmRetry config through plugin validation', async () => { + const ctx = new Context() + await expect(ctx.plugin(cliDemo, { + provider: 'mock', + model: 'mock', + workspaceContext: false, + llmRetry: { maxTransientRetries: 2 }, + } as never)).rejects.toThrow(/llmRetry/) + }) + it('parses defaults, explicit options, spaces, and an option-like task after --', () => { expect(parseCliArgs(['task with spaces'])).toEqual({ kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces', diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 29f985c8e7..cd38ab3cd2 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -82,6 +82,8 @@ export interface Config { resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] + /** Invalid at app level; configure `retryPolicy` under each provider. */ + llmRetry?: never } export const Config: z = z.object({ @@ -106,6 +108,8 @@ export const Config: z = z.object({ goals: z.union([z.const(false), agentCore.GoalConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), + // Provider retryPolicy makes a top-level llmRetry invalid. + llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index cdab42289b..37c8609733 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' -import type { Context } from 'cordis' +import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as tuiAgent from '../src/index.ts' @@ -21,6 +21,16 @@ function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall } describe('dsh-tui-demo app', () => { + it('rejects app-level llmRetry config through plugin validation', async () => { + const ctx = new Context() + await expect(ctx.plugin(tuiAgent, { + provider: 'mock', + model: 'mock', + workspaceContext: false, + llmRetry: { maxTransientRetries: 2 }, + } as never)).rejects.toThrow(/llmRetry/) + }) + it('composes the TUI cluster around one fresh exact session identity', () => { const { ctx, calls } = recordingContext() tuiAgent.composeTuiApp(ctx, { diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 452b395d4d..e8d2357063 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,9 +6,9 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, failure, and scheduled delay. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. -The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, has a unique step record and correct provider-policy retry number, and carries a valid mode-specific budget and bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries a producer-canonical policy key consistent with its mode and finite budget, binds normal failures and every scheduled delay to that policy, has a unique step record and correct provider-policy retry number, and carries a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 7de5d4f06b..f759697b9e 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -11,6 +11,7 @@ import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { providerForClosedStep } from './history.ts' +import { retryPolicyKey } from './policy-key.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -20,6 +21,7 @@ declare module '@deepseek-ai/dsh-session' { step: number provider: string mode: 'normal' + policyKey: string retry: number maxRetries: number delayMs: number @@ -29,6 +31,7 @@ declare module '@deepseek-ai/dsh-session' { step: number provider: string mode: 'always' + policyKey: string retry: number delayMs: number failure: LlmFailure @@ -121,6 +124,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna failure: LlmFailure, provider: string, policy: ResolvedRetryPolicy, + policyKey: string, retry: number, delayMs: number, signal: AbortSignal, @@ -133,6 +137,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna step, provider, mode: policy.mode, + policyKey, retry, maxRetries: policy.maxRetries, delayMs, @@ -143,6 +148,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna step, provider, mode: policy.mode, + policyKey, retry, delayMs, failure, @@ -192,6 +198,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna return next() } + const policyKey = retryPolicyKey(policy) const firstPriorStep = step - priorFailures.length const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => event.type === 'llm/retry' @@ -199,7 +206,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna && event.data.step >= firstPriorStep && event.data.step < step && event.data.provider === provider - && event.data.mode === policy.mode, + && event.data.policyKey === policyKey, ) const previousRetry = priorPolicyRetry?.data.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() @@ -218,7 +225,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna delayMs = localDelay(policy, retry, random) } - return backoff(agent, turn, step, failure, provider, policy, retry, delayMs, signal) + return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal) } const disposeListener = ctx.on('agent/request-error', ( diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 0cabbd6365..ddf5e8b975 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -2,9 +2,9 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { providerForClosedStep } from './history.ts' +import { parseRetryPolicyKey } from './policy-key.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -20,30 +20,46 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, provider, mode, retry, delayMs } = event.data + const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data if (!Number.isSafeInteger(retry) || retry < 1) { fail('llm/retry retry must be a positive safe integer') } if (typeof provider !== 'string' || provider.length === 0) { fail('llm/retry provider must be non-empty string') } + const keyedPolicy = parseRetryPolicyKey(policyKey) + if (keyedPolicy === undefined) { + fail('llm/retry policyKey must encode a canonical resolved policy') + } switch (mode) { case 'normal': { const { maxRetries } = event.data if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) } + if (keyedPolicy.mode !== 'normal') { + fail(`llm/retry mode normal must match policyKey mode ${keyedPolicy.mode}`) + } + if (keyedPolicy.maxRetries !== maxRetries) { + fail(`llm/retry maxRetries ${maxRetries} must match policyKey`) + } + if (!keyedPolicy.retryableCodes.includes(event.data.failure.code)) { + fail(`llm/retry failure code ${event.data.failure.code} must be eligible under policyKey`) + } break } case 'always': + if (keyedPolicy.mode !== 'always') { + fail(`llm/retry mode always must match policyKey mode ${keyedPolicy.mode}`) + } if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries') break default: fail(`llm/retry mode must be normal or always, got ${String(mode)}`) } if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) - || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) { - fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) + || delayMs < 0 || delayMs > keyedPolicy.maxDelayMs) { + fail(`llm/retry delayMs must be a finite number within policyKey range 0..${keyedPolicy.maxDelayMs}`) } const turnStartIndex = history.findLastIndex(prior => @@ -86,7 +102,7 @@ function validateRetry( index > lastSuccessIndex && prior.type === 'llm/retry' && prior.data.provider === provider - && prior.data.mode === mode + && prior.data.policyKey === policyKey )) const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1 if (retry !== expectedRetry) { diff --git a/packages/llm/llm-retry/src/policy-key.ts b/packages/llm/llm-retry/src/policy-key.ts new file mode 100644 index 0000000000..2103d87920 --- /dev/null +++ b/packages/llm/llm-retry/src/policy-key.ts @@ -0,0 +1,100 @@ +/** Canonical durable identity for resolved retry policies. @module @deepseek-ai/dsh-llm-retry/policy-key */ + +import type { ResolvedRetryBackoff, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +function parseBackoff( + tuple: readonly unknown[], + offset: number, +): ResolvedRetryBackoff | undefined { + const initialDelayMs = tuple[offset] + const maxDelayMs = tuple[offset + 1] + const jitterRatio = tuple[offset + 2] + if (typeof initialDelayMs !== 'number' || !Number.isFinite(initialDelayMs) + || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS + || typeof maxDelayMs !== 'number' || !Number.isFinite(maxDelayMs) + || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS + || initialDelayMs > maxDelayMs + || typeof jitterRatio !== 'number' || !Number.isFinite(jitterRatio) + || jitterRatio < 0 || jitterRatio > 1) { + return undefined + } + return { initialDelayMs, maxDelayMs, jitterRatio } +} + +/** + * Derive the canonical durable key for one fully resolved provider policy. + * Retryable-code order is normalized because eligibility uses set membership. + * @param policy - immutable policy captured from the serving registration. + * @returns canonical JSON tuple containing every behavior-affecting field. + */ +export function retryPolicyKey(policy: ResolvedRetryPolicy): string { + if (policy.mode === 'always') { + return JSON.stringify([ + policy.mode, + policy.initialDelayMs, + policy.maxDelayMs, + policy.jitterRatio, + ]) + } + return JSON.stringify([ + policy.mode, + policy.maxRetries, + [...policy.retryableCodes].sort(), + policy.initialDelayMs, + policy.maxDelayMs, + policy.jitterRatio, + ]) +} + +/** + * Parse a producer-canonical policy key from durable input. + * @param value - untrusted persisted event field. + * @returns the resolved policy encoded by the key, or `undefined` for any non-canonical value. + */ +export function parseRetryPolicyKey(value: unknown): ResolvedRetryPolicy | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined + let tuple: unknown + try { + tuple = JSON.parse(value) as unknown + } catch (_invalidPolicyKeyJson) { + return undefined + } + if (!Array.isArray(tuple)) return undefined + const items = tuple as readonly unknown[] + const mode = items[0] + let policy: ResolvedRetryPolicy + switch (mode) { + case 'always': { + if (items.length !== 4) return undefined + const backoff = parseBackoff(items, 1) + if (backoff === undefined) return undefined + policy = Object.freeze({ mode, ...backoff }) + break + } + case 'normal': { + if (items.length !== 6) return undefined + const maxRetries = items[1] + const retryableCodes = items[2] + const backoff = parseBackoff(items, 3) + if (!Number.isSafeInteger(maxRetries) || (maxRetries as number) < 0 + || !Array.isArray(retryableCodes) || retryableCodes.length === 0 + || (retryableCodes as readonly unknown[]) + .some(code => typeof code !== 'string' || code.length === 0) + || new Set(retryableCodes).size !== retryableCodes.length + || backoff === undefined) { + return undefined + } + policy = Object.freeze({ + mode, + maxRetries: maxRetries as number, + retryableCodes: Object.freeze(retryableCodes as string[]), + ...backoff, + }) + break + } + default: + return undefined + } + return retryPolicyKey(policy) === value ? policy : undefined +} diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index aa4030a886..a7fd36993a 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -27,7 +27,10 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { } const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } -const normal = { provider: 'mock', mode: 'normal' as const } +const normalPolicyKey = (maxRetries: number): string => + `["normal",${maxRetries},["RATE_LIMIT"],1,10000,0]` +const alwaysPolicyKey = '["always",1,10000,0]' +const normal = { provider: 'mock', mode: 'normal' as const, policyKey: normalPolicyKey(2) } describe('llm-retry invariants', () => { it('has no provider without the requested closed step', () => { @@ -65,7 +68,8 @@ describe('llm-retry invariants', () => { }) const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') zeroDelay.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 1, delayMs: 0, failure, + turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(1), + retry: 1, maxRetries: 1, delayMs: 0, failure, }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() @@ -80,6 +84,7 @@ describe('llm-retry invariants', () => { step: 1, provider: 'mock', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 500, failure, @@ -91,6 +96,7 @@ describe('llm-retry invariants', () => { step: 1, provider: 'mock', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, maxRetries: 2, delayMs: 500, @@ -99,6 +105,65 @@ describe('llm-retry invariants', () => { }).toThrow(/always mode must omit maxRetries/) }) + it('binds event mode and finite budget to the canonical policy key', async () => { + const ctx = await setup() + const normalModeMismatch = closeStep(ctx, 'retry-invariant-normal-mode-key') + expect(() => { + normalModeMismatch.append('llm/retry', { + turn: 1, step: 1, ...normal, policyKey: alwaysPolicyKey, + retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/mode normal must match policyKey mode always/) + + const alwaysModeMismatch = closeStep(ctx, 'retry-invariant-always-mode-key') + expect(() => { + alwaysModeMismatch.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + policyKey: normalPolicyKey(2), + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/mode always must match policyKey mode normal/) + + const budgetMismatch = closeStep(ctx, 'retry-invariant-budget-key') + expect(() => { + budgetMismatch.append('llm/retry', { + turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), + retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/maxRetries 2 must match policyKey/) + }) + + it('binds the failure code and scheduled delay to the canonical policy key', async () => { + const ctx = await setup() + const ineligibleFailure = closeStep(ctx, 'retry-invariant-failure-code-key') + expect(() => { + ineligibleFailure.append('llm/retry', { + turn: 1, step: 1, ...normal, + retry: 1, maxRetries: 2, delayMs: 1, + failure: { message: 'authentication failed', code: 'AUTH', status: 401 }, + }) + }).toThrow(/failure code AUTH must be eligible under policyKey/) + + const overPolicyDelay = closeStep(ctx, 'retry-invariant-delay-key') + expect(() => { + overPolicyDelay.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + policyKey: '["always",1,1,0]', + retry: 1, + delayMs: 2, + failure, + }) + }).toThrow(/within policyKey range 0\.\.1/) + }) + it('rejects empty providers and unknown modes from hostile durable input', async () => { const ctx = await setup() const emptyProvider = closeStep(ctx, 'retry-invariant-empty-provider') @@ -108,6 +173,7 @@ describe('llm-retry invariants', () => { step: 1, provider: '', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 1, failure, @@ -121,11 +187,26 @@ describe('llm-retry invariants', () => { step: 1, provider: 'mock', mode: 'sometimes', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 1, failure, } as never) }).toThrow(/mode must be normal or always/) + + const emptyPolicyKey = closeStep(ctx, 'retry-invariant-empty-policy-key') + expect(() => { + emptyPolicyKey.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + policyKey: '', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/policyKey must encode a canonical resolved policy/) }) it.each([ @@ -206,6 +287,7 @@ describe('llm-retry invariants', () => { step: 1, provider: 'mock', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 1, failure, @@ -219,6 +301,7 @@ describe('llm-retry invariants', () => { step: 1, provider: 'other', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 1, failure, @@ -239,6 +322,7 @@ describe('llm-retry invariants', () => { step: 1, provider: 'mock', mode: 'always', + policyKey: alwaysPolicyKey, retry: 1, delayMs: 1, failure, @@ -260,23 +344,27 @@ describe('llm-retry invariants', () => { const ctx = await setup() const duplicate = closeStep(ctx, 'retry-invariant-duplicate') duplicate.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), + retry: 1, maxRetries: 3, delayMs: 1, failure, }) expect(() => { duplicate.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 2, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), + retry: 2, maxRetries: 3, delayMs: 1, failure, }) }).toThrow(/duplicates the retry record/) const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') nonIncreasing.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), + retry: 1, maxRetries: 3, delayMs: 1, failure, }) nonIncreasing.append('step/start', { turn: 1, step: 2 }) nonIncreasing.append('step/end', { turn: 1, step: 2 }) expect(() => { nonIncreasing.append('llm/retry', { - turn: 1, step: 2, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 2, ...normal, policyKey: normalPolicyKey(3), + retry: 1, maxRetries: 3, delayMs: 1, failure, }) }).toThrow(/must equal provider policy retry 2/) }) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 41c70b257d..d99d255e7b 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -44,6 +44,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) step: 1, provider: 'mock', mode: 'always', + policyKey: '["always",500,10000,0.1]', retry: 1, delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, diff --git a/packages/llm/llm-retry/tests/policy-key.spec.ts b/packages/llm/llm-retry/tests/policy-key.spec.ts new file mode 100644 index 0000000000..99c7da7802 --- /dev/null +++ b/packages/llm/llm-retry/tests/policy-key.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { parseRetryPolicyKey, retryPolicyKey } from '../src/policy-key.ts' + +describe('retry policy durable key', () => { + it('includes every policy field while normalizing code-set order', () => { + const first = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 4, + retryableCodes: ['SERVER', 'RATE_LIMIT'], + backoff: { initialDelayMs: 3, maxDelayMs: 9, jitterRatio: 0.25 }, + }, 'first') + const reordered = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 4, + retryableCodes: ['RATE_LIMIT', 'SERVER'], + backoff: { initialDelayMs: 3, maxDelayMs: 9, jitterRatio: 0.25 }, + }, 'reordered') + const key = retryPolicyKey(first) + + expect(key).toBe('["normal",4,["RATE_LIMIT","SERVER"],3,9,0.25]') + expect(retryPolicyKey(reordered)).toBe(key) + expect(parseRetryPolicyKey(key)).toEqual(reordered) + }) + + it('round-trips always mode', () => { + const policy = resolveRetryPolicy({ + mode: 'always', + backoff: { initialDelayMs: 2, maxDelayMs: 8, jitterRatio: 1 }, + }, 'always') + const key = retryPolicyKey(policy) + + expect(key).toBe('["always",2,8,1]') + expect(parseRetryPolicyKey(key)).toEqual(policy) + }) + + it.each([ + undefined, + '', + '{', + '{}', + '["sometimes",1,2,0]', + '["always",1,2]', + '["always","1",2,0]', + '["always",1e400,2,0]', + '["always",0,2,0]', + `["always",${MAX_TIMER_DELAY_MS + 1},${MAX_TIMER_DELAY_MS + 1},0]`, + '["always",1,"2",0]', + '["always",1,1e400,0]', + '["always",1,0,0]', + `["always",1,${MAX_TIMER_DELAY_MS + 1},0]`, + '["always",2,1,0]', + '["always",1,2,"0"]', + '["always",1,2,1e400]', + '["always",1,2,-0.1]', + '["always",1,2,1.1]', + '["normal",2,["SERVER"],1,2]', + '["normal","2",["SERVER"],1,2,0]', + '["normal",-1,["SERVER"],1,2,0]', + '["normal",2,"SERVER",1,2,0]', + '["normal",2,[],1,2,0]', + '["normal",2,[1],1,2,0]', + '["normal",2,[""],1,2,0]', + '["normal",2,["SERVER","SERVER"],1,2,0]', + '["normal",2,["SERVER"],2,1,0]', + '["normal",2,["SERVER","RATE_LIMIT"],1,2,0]', + ])('rejects non-canonical durable input %#', (value) => { + expect(parseRetryPolicyKey(value)).toBeUndefined() + }) +}) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 2533eb045f..7677cbac20 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -184,6 +184,7 @@ describe('provider-routed retry policy', () => { step: 1, provider: 'mock', mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"],500,10000,0.1]', retry: 1, maxRetries: 2, delayMs: 500, @@ -561,6 +562,110 @@ describe('provider-routed retry policy', () => { }, ) + it('starts a new retry history when a same-mode route replacement changes policy', async () => { + vi.useFakeTimers() + const oldAdapter = new ScriptedAdapter([ + new LlmError('old route failed', 'AUTH'), + ]) + const mounted = await harness(oldAdapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) }) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-policy-replacement'), { + provider: 'mock', + model: 'mock', + }) + const first = waitForRetry(context, agent, 1) + + agent.followup([{ type: 'text', text: 'replace policy between attempts' }]) + expect((await first).data).toMatchObject({ + mode: 'always', + retry: 1, + delayMs: 1, + }) + + mounted.disposeAdapter() + const replacement = new ScriptedAdapter([ + new LlmError('replacement failed', 'AUTH'), + textResponse('replacement recovered'), + ]) + replacement.configureRetryPolicies({ mock: alwaysConfig({ + initialDelayMs: 3, + maxDelayMs: 9, + }) }) + context.llm.registerAdapter(['mock'], replacement) + + const second = waitForRetry(context, agent, 1) + await vi.advanceTimersByTimeAsync(1) + expect((await second).data).toMatchObject({ + mode: 'always', + retry: 1, + delayMs: 3, + }) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(3) + await idle + + expect(oldAdapter.requests).toHaveLength(1) + expect(replacement.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + policyKey: event.data.policyKey, + retry: event.data.retry, + }))).toEqual([ + { policyKey: '["always",1,1,0]', retry: 1 }, + { policyKey: '["always",3,9,0]', retry: 1 }, + ]) + }) + + it('continues retry history when a replacement only reorders retryable codes', async () => { + vi.useFakeTimers() + const oldAdapter = new ScriptedAdapter([ + new LlmError('old route failed', 'SERVER'), + ]) + const mounted = await harness(oldAdapter, { mock: normalConfig({ + maxRetries: 2, + retryableCodes: ['SERVER', 'RATE_LIMIT'], + backoff: { initialDelayMs: 1, maxDelayMs: 4 }, + }) }) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-policy-code-order'), { + provider: 'mock', + model: 'mock', + }) + const first = waitForRetry(context, agent, 1) + + agent.followup([{ type: 'text', text: 'replace equivalent policy between attempts' }]) + const firstEvent = await first + expect(firstEvent.data.delayMs).toBe(1) + + mounted.disposeAdapter() + const replacement = new ScriptedAdapter([ + new LlmError('replacement failed', 'SERVER'), + textResponse('replacement recovered'), + ]) + replacement.configureRetryPolicies({ mock: normalConfig({ + maxRetries: 2, + retryableCodes: ['RATE_LIMIT', 'SERVER'], + backoff: { initialDelayMs: 1, maxDelayMs: 4 }, + }) }) + context.llm.registerAdapter(['mock'], replacement) + + const second = waitForRetry(context, agent, 2) + await vi.advanceTimersByTimeAsync(1) + const secondEvent = await second + expect(secondEvent.data).toMatchObject({ retry: 2, delayMs: 2 }) + expect(secondEvent.data.policyKey).toBe(firstEvent.data.policyKey) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(2) + await idle + + expect(oldAdapter.requests).toHaveLength(1) + expect(replacement.requests).toHaveLength(2) + }) + it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 693c2852c1..44ac1a35fb 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -271,6 +271,7 @@ describe('TUI terminal-state snapshots', () => { step: 1, provider: 'mock', mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -301,6 +302,7 @@ describe('TUI terminal-state snapshots', () => { step: 1, provider: 'mock', mode: 'always', + policyKey: '["always",1,10000,0]', retry: 1, delayMs: 1_000, failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 2dd3ed2098..916ec90580 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1296,6 +1296,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, provider: 'mock', mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -1330,6 +1331,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, provider: 'mock', mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -1340,6 +1342,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 2, provider: 'mock', mode: 'normal', + policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]', retry: 2, maxRetries: 2, delayMs: 1_000, @@ -1350,6 +1353,7 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 3, provider: 'mock', mode: 'always', + policyKey: '["always",1,10000,0]', retry: 1, delayMs: 2_000, failure: { message: 'retry without limit', code: 'AUTH', status: 401 }, From fcd355bf45cb13cd500dd56aec6918ee627f7060 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 16:28:31 +0800 Subject: [PATCH 09/41] fix(examples): remove legacy retry rejection --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +-- .../2026-07-24-provider-retry-policies.md | 2 +- .../2026-07-24-provider-retry-policies.zh.md | 2 +- docs/config-catalog.md | 8 ----- packages/examples/acp-demo/src/index.ts | 4 --- .../examples/acp-demo/tests/acp-agent.spec.ts | 10 ------ .../examples/acp-demo/tests/built-bin.e2e.ts | 13 -------- .../examples/agent-spine-demo/src/index.ts | 5 --- .../agent-spine-demo/tests/agent-core.spec.ts | 8 ----- packages/examples/cli-demo/src/index.ts | 4 --- .../examples/cli-demo/tests/built-bin.e2e.ts | 33 ------------------- packages/examples/cli-demo/tests/cli.spec.ts | 10 ------ packages/examples/tui-demo/src/index.ts | 4 --- .../examples/tui-demo/tests/tui-agent.spec.ts | 10 ------ 14 files changed, 4 insertions(+), 113 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index fdc0fadce3..14b24b70b4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: cd39717c03f2a7714b2f5e93404efb0d2d17a6da -2026-07-24-provider-retry-policies.zh.md: 1768f67dfb98757d964589693a108f946924749b +2026-07-24-provider-retry-policies.md: ed6b401c792f85eec6ca1c52495733222ea998a1 +2026-07-24-provider-retry-policies.zh.md: 2a4853ceec71d0ce78a1225f455575035f0bca13 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index cd39717c03..ed6b401c79 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -56,7 +56,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit and plugin-validation tests select policies from the failed request's serving registration, reject top-level `llmRetry` at the spine, CLI, TUI, and ACP schemas, separate different same-mode policies while preserving histories across reordered code sets, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Published Loader fixtures reject the invalid app-level key in CLI and ACP and the invalid bundle-level key when loading the spine directly. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests validate the canonical policy tuple, bind its provider to the request header, bind its failure code and delay to the encoded policy, and bind its retry number to the active provider policy key; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate different same-mode policies while preserving histories across reordered code sets, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests validate the canonical policy tuple, bind its provider to the request header, bind its failure code and delay to the encoded policy, and bind its retry number to the active provider policy key; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 1768f67dfb..2a4853ceec 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -56,7 +56,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试与插件校验测试根据失败请求实际使用的注册项选择策略、在主干、CLI、TUI 与 ACP schema 拒绝顶层 `llmRetry`、分离模式相同但策略不同的替换路由历史,同时在错误代码集合仅顺序不同时延续历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。发布版 Loader fixture(测试前置数据)会在 CLI 与 ACP 中拒绝无效的应用级配置键,并在直接加载主干时拒绝无效的 bundle 级配置键。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会校验规范策略元组、将事件中的提供方绑定到请求头、将失败代码与延迟绑定到编码后的策略,并将重试编号绑定到活跃的提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离模式相同但策略不同的替换路由历史,同时在错误代码集合仅顺序不同时延续历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会校验规范策略元组、将事件中的提供方绑定到请求头、将失败代码与延迟绑定到编码后的策略,并将重试编号绑定到活跃的提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ed704ff08b..9fa6280870 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -73,8 +73,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ goals?: agentCore.GoalConfig | false - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } ``` @@ -162,8 +160,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Invalid at bundle level; configure `retryPolicy` under each provider. */ - llmRetry?: never } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -265,8 +261,6 @@ export interface Config { toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } ``` @@ -1804,8 +1798,6 @@ export interface Config { resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } ``` diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index c4d52d0053..d7161c589f 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -67,8 +67,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ goals?: agentCore.GoalConfig | false - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } // Each front door owns a complete, directly readable config schema; extracting @@ -94,8 +92,6 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - // Provider retryPolicy makes a top-level llmRetry invalid. - llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 7c32da8cfa..e932c7fb17 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -76,16 +76,6 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } describe('dsh-acp-demo composition', () => { - it('rejects app-level llmRetry config through plugin validation', async () => { - const ctx = new Context() - await expect(ctx.plugin(acpAgent, { - provider: 'mock', - model: 'mock', - workspaceContext: false, - llmRetry: { maxTransientRetries: 2 }, - } as never)).rejects.toThrow(/llmRetry/) - }) - it('brings up the spine + persistence + the ACP bridge', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index dd7139815f..870a43cf88 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -208,19 +208,6 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n expect(stderr).toContain('config file not found') }, 30_000) - it('rejects legacy app-level llmRetry through the published Loader path', async () => { - consumer = await makeConsumer() - const configPath = join(consumer, 'cordis.yml') - const config = await readFile(configPath, 'utf8') - await writeFile(configPath, config.replace( - ' workspaceContext: false', - ' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2', - )) - - const { code, stderr } = await runBinExpectingExit('./cordis.yml', consumer) - expect(code).not.toBe(0) - expect(stderr).toContain('llmRetry') - }, 30_000) }) /** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 397966813f..d2a65844ea 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -112,8 +112,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Invalid at bundle level; configure `retryPolicy` under each provider. */ - llmRetry?: never } /** The skill config schema exported for app packages that forward `skills`. */ @@ -154,9 +152,6 @@ export const Config = z.intersect([ toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - // Schemastery preserves unknown object properties. A top-level llmRetry is - // known-but-impossible because provider retryPolicy owns this configuration. - llmRetry: z.never(), }) as unknown as z>, ]) as unknown as z diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 33e8ddc6cd..2051aee4b8 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -594,14 +594,6 @@ describe('dsh-agent-spine-demo bundle', () => { expect(agentCore.name).toBe('agent-spine-demo') }) - it('rejects bundle-level llmRetry config through plugin validation', async () => { - const ctx = new Context() - await expect(ctx.plugin(agentCore, { - workspaceContext: false, - llmRetry: { maxTransientRetries: 2 }, - } as never)).rejects.toThrow(/llmRetry/) - }) - it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { // A default export would make `unwrapExports` collapse this inject-less namespace and silently // drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard. diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index e9bbb0a237..bfec7c9a4f 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -52,8 +52,6 @@ export interface Config { toolTasks?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } // Each front door keeps a complete Loader schema so its deployment contract is @@ -75,8 +73,6 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), - // Provider retryPolicy makes a top-level llmRetry invalid. - llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index ddc07a93a1..5c3a6ad62e 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -192,39 +192,6 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { } }, 30_000) - it('rejects legacy app-level llmRetry through the published Loader path', async () => { - consumer = await makeConsumer() - const configPath = join(consumer, 'cordis.yml') - const config = await readFile(configPath, 'utf8') - await writeFile(configPath, config.replace( - ' workspaceContext: false', - ' workspaceContext: false\n llmRetry:\n maxTransientRetries: 2', - )) - - const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task']) - expect(result.code).not.toBe(0) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('llmRetry') - }, 30_000) - - it('rejects legacy bundle-level llmRetry when the published spine is loaded directly', async () => { - consumer = await makeConsumer() - await writeFile(join(consumer, 'cordis.yml'), [ - '- id: spine', - " name: '@deepseek-ai/dsh-agent-spine-demo'", - ' config:', - ' workspaceContext: false', - ' llmRetry:', - ' maxTransientRetries: 2', - '', - ].join('\n')) - - const result = await runBuiltBin(consumer, ['--config', './cordis.yml', 'task']) - expect(result.code).not.toBe(0) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('llmRetry') - }, 30_000) - describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => { it.each([ ['SIGINT', 130], diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 786662caba..bf681af2f8 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -173,16 +173,6 @@ afterEach(async () => { }) describe('parseCliArgs', () => { - it('rejects app-level llmRetry config through plugin validation', async () => { - const ctx = new Context() - await expect(ctx.plugin(cliDemo, { - provider: 'mock', - model: 'mock', - workspaceContext: false, - llmRetry: { maxTransientRetries: 2 }, - } as never)).rejects.toThrow(/llmRetry/) - }) - it('parses defaults, explicit options, spaces, and an option-like task after --', () => { expect(parseCliArgs(['task with spaces'])).toEqual({ kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces', diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index cd38ab3cd2..29f985c8e7 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -82,8 +82,6 @@ export interface Config { resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] - /** Invalid at app level; configure `retryPolicy` under each provider. */ - llmRetry?: never } export const Config: z = z.object({ @@ -108,8 +106,6 @@ export const Config: z = z.object({ goals: z.union([z.const(false), agentCore.GoalConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), - // Provider retryPolicy makes a top-level llmRetry invalid. - llmRetry: z.never(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 37c8609733..19d5802e71 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -21,16 +21,6 @@ function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall } describe('dsh-tui-demo app', () => { - it('rejects app-level llmRetry config through plugin validation', async () => { - const ctx = new Context() - await expect(ctx.plugin(tuiAgent, { - provider: 'mock', - model: 'mock', - workspaceContext: false, - llmRetry: { maxTransientRetries: 2 }, - } as never)).rejects.toThrow(/llmRetry/) - }) - it('composes the TUI cluster around one fresh exact session identity', () => { const { ctx, calls } = recordingContext() tuiAgent.composeTuiApp(ctx, { From 19a1aec89e82c8c37c130fc01c2ee77b0760e1fa Mon Sep 17 00:00:00 2001 From: Turtle Date: Sun, 26 Jul 2026 01:16:41 +0800 Subject: [PATCH 10/41] refactor(llm-retry): simplify policy history validation --- ...26-07-24-provider-retry-policies.i18n.yaml | 4 +- .../2026-07-24-provider-retry-policies.md | 2 +- .../2026-07-24-provider-retry-policies.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 2 +- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/src/index.ts | 14 +- packages/llm/llm-retry/src/invariant.ts | 25 +- packages/llm/llm-retry/src/policy-key.ts | 100 ----- .../llm/llm-retry/tests/invariant.spec.ts | 398 ++++-------------- .../llm/llm-retry/tests/policy-key.spec.ts | 71 ---- packages/llm/llm-retry/tests/retry.spec.ts | 227 +++------- 12 files changed, 186 insertions(+), 663 deletions(-) delete mode 100644 packages/llm/llm-retry/src/policy-key.ts delete mode 100644 packages/llm/llm-retry/tests/policy-key.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml index 14b24b70b4..7e865d0d6d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: ed6b401c792f85eec6ca1c52495733222ea998a1 -2026-07-24-provider-retry-policies.zh.md: 2a4853ceec71d0ce78a1225f455575035f0bca13 +2026-07-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c +2026-07-24-provider-retry-policies.zh.md: 788f1f1963861e1b46ff0d8798e53e01bd9892b4 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md index ed6b401c79..1831ce6b96 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -56,7 +56,7 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro ## Verification -Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate different same-mode policies while preserving histories across reordered code sets, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests validate the canonical policy tuple, bind its provider to the request header, bind its failure code and delay to the encoded policy, and bind its retry number to the active provider policy key; TUI tests render finite and infinite limits. +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md index 2a4853ceec..788f1f1963 100644 --- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -56,7 +56,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之 ## 验证 -适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离模式相同但策略不同的替换路由历史,同时在错误代码集合仅顺序不同时延续历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会校验规范策略元组、将事件中的提供方绑定到请求头、将失败代码与延迟绑定到编码后的策略,并将重试编号绑定到活跃的提供方策略键;TUI 测试会渲染有限和无限上限。 +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9fa6280870..e9dfe554be 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -700,7 +700,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8cd8c78110..c183a78abf 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -294,7 +294,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- } ``` -Source: [`packages/llm/llm-retry/src/index.ts:19`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 2becaa7645..f6e5e9e524 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -8,7 +8,7 @@ Both modes use bounded exponential backoff with symmetric jitter. A valid `provi Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. -The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries a producer-canonical policy key consistent with its mode and finite budget, binds normal failures and every scheduled delay to that policy, has a unique step record and correct provider-policy retry number, and carries a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index f759697b9e..03eeee25cf 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -11,7 +11,6 @@ import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { providerForClosedStep } from './history.ts' -import { retryPolicyKey } from './policy-key.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -84,6 +83,19 @@ function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => nu return Math.min(exponential * jitter, config.maxDelayMs) } +function retryPolicyKey(policy: ResolvedRetryPolicy): string { + return policy.mode === 'always' + ? JSON.stringify([policy.mode, policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio]) + : JSON.stringify([ + policy.mode, + policy.maxRetries, + [...policy.retryableCodes].sort(), + policy.initialDelayMs, + policy.maxDelayMs, + policy.jitterRatio, + ]) +} + function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false) return new Promise((resolve) => { diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 41fb5155b5..9ea66d9793 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -3,9 +3,9 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { LlmFailure } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { providerForClosedStep } from './history.ts' -import { parseRetryPolicyKey } from './policy-key.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -54,11 +54,10 @@ function validateRetry( fail('llm/retry retry must be a positive safe integer') } if (typeof provider !== 'string' || provider.length === 0) { - fail('llm/retry provider must be non-empty string') + fail('llm/retry provider must be a non-empty string') } - const keyedPolicy = parseRetryPolicyKey(policyKey) - if (keyedPolicy === undefined) { - fail('llm/retry policyKey must encode a canonical resolved policy') + if (typeof policyKey !== 'string' || policyKey.length === 0) { + fail('llm/retry policyKey must be a non-empty string') } switch (mode) { case 'normal': { @@ -66,29 +65,17 @@ function validateRetry( if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) } - if (keyedPolicy.mode !== 'normal') { - fail(`llm/retry mode normal must match policyKey mode ${keyedPolicy.mode}`) - } - if (keyedPolicy.maxRetries !== maxRetries) { - fail(`llm/retry maxRetries ${maxRetries} must match policyKey`) - } - if (!keyedPolicy.retryableCodes.includes(failure.code)) { - fail(`llm/retry failure code ${failure.code} must be eligible under policyKey`) - } break } case 'always': - if (keyedPolicy.mode !== 'always') { - fail(`llm/retry mode always must match policyKey mode ${keyedPolicy.mode}`) - } if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries') break default: fail(`llm/retry mode must be normal or always, got ${String(mode)}`) } if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) - || delayMs < 0 || delayMs > keyedPolicy.maxDelayMs) { - fail(`llm/retry delayMs must be a finite number within policyKey range 0..${keyedPolicy.maxDelayMs}`) + || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) { + fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) } const turnStartIndex = history.findLastIndex(prior => diff --git a/packages/llm/llm-retry/src/policy-key.ts b/packages/llm/llm-retry/src/policy-key.ts deleted file mode 100644 index 2103d87920..0000000000 --- a/packages/llm/llm-retry/src/policy-key.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Canonical durable identity for resolved retry policies. @module @deepseek-ai/dsh-llm-retry/policy-key */ - -import type { ResolvedRetryBackoff, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' - -function parseBackoff( - tuple: readonly unknown[], - offset: number, -): ResolvedRetryBackoff | undefined { - const initialDelayMs = tuple[offset] - const maxDelayMs = tuple[offset + 1] - const jitterRatio = tuple[offset + 2] - if (typeof initialDelayMs !== 'number' || !Number.isFinite(initialDelayMs) - || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS - || typeof maxDelayMs !== 'number' || !Number.isFinite(maxDelayMs) - || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS - || initialDelayMs > maxDelayMs - || typeof jitterRatio !== 'number' || !Number.isFinite(jitterRatio) - || jitterRatio < 0 || jitterRatio > 1) { - return undefined - } - return { initialDelayMs, maxDelayMs, jitterRatio } -} - -/** - * Derive the canonical durable key for one fully resolved provider policy. - * Retryable-code order is normalized because eligibility uses set membership. - * @param policy - immutable policy captured from the serving registration. - * @returns canonical JSON tuple containing every behavior-affecting field. - */ -export function retryPolicyKey(policy: ResolvedRetryPolicy): string { - if (policy.mode === 'always') { - return JSON.stringify([ - policy.mode, - policy.initialDelayMs, - policy.maxDelayMs, - policy.jitterRatio, - ]) - } - return JSON.stringify([ - policy.mode, - policy.maxRetries, - [...policy.retryableCodes].sort(), - policy.initialDelayMs, - policy.maxDelayMs, - policy.jitterRatio, - ]) -} - -/** - * Parse a producer-canonical policy key from durable input. - * @param value - untrusted persisted event field. - * @returns the resolved policy encoded by the key, or `undefined` for any non-canonical value. - */ -export function parseRetryPolicyKey(value: unknown): ResolvedRetryPolicy | undefined { - if (typeof value !== 'string' || value.length === 0) return undefined - let tuple: unknown - try { - tuple = JSON.parse(value) as unknown - } catch (_invalidPolicyKeyJson) { - return undefined - } - if (!Array.isArray(tuple)) return undefined - const items = tuple as readonly unknown[] - const mode = items[0] - let policy: ResolvedRetryPolicy - switch (mode) { - case 'always': { - if (items.length !== 4) return undefined - const backoff = parseBackoff(items, 1) - if (backoff === undefined) return undefined - policy = Object.freeze({ mode, ...backoff }) - break - } - case 'normal': { - if (items.length !== 6) return undefined - const maxRetries = items[1] - const retryableCodes = items[2] - const backoff = parseBackoff(items, 3) - if (!Number.isSafeInteger(maxRetries) || (maxRetries as number) < 0 - || !Array.isArray(retryableCodes) || retryableCodes.length === 0 - || (retryableCodes as readonly unknown[]) - .some(code => typeof code !== 'string' || code.length === 0) - || new Set(retryableCodes).size !== retryableCodes.length - || backoff === undefined) { - return undefined - } - policy = Object.freeze({ - mode, - maxRetries: maxRetries as number, - retryableCodes: Object.freeze(retryableCodes as string[]), - ...backoff, - }) - break - } - default: - return undefined - } - return retryPolicyKey(policy) === value ? policy : undefined -} diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 056c0c1721..b9ec18c9f7 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -28,13 +28,26 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { } const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } -const normalPolicyKey = (maxRetries: number): string => - `["normal",${maxRetries},["RATE_LIMIT"],1,10000,0]` -const alwaysPolicyKey = '["always",1,10000,0]' -const normal = { provider: 'mock', mode: 'normal' as const, policyKey: normalPolicyKey(2) } +const normal = { + provider: 'mock', + mode: 'normal' as const, + policyKey: 'normal-policy', + retry: 1, + maxRetries: 2, + delayMs: 1, + failure, +} +const always = { + provider: 'mock', + mode: 'always' as const, + policyKey: 'always-policy', + retry: 1, + delayMs: 1, + failure, +} describe('llm-retry invariants', () => { - it('has no provider without the requested closed step', () => { + it('has no provider without the requested closed step or a route marker', () => { expect(providerForClosedStep([], 1, 1)).toBeUndefined() expect(providerForClosedStep([{ type: 'step/end', @@ -42,82 +55,31 @@ describe('llm-retry invariants', () => { }] as never, 1, 1)).toBeUndefined() }) - it('inherits the latest provider across a turn boundary when the header is unchanged', () => { - expect(providerForClosedStep([ - { type: 'turn/start', data: { turn: 1 } }, - { - type: 'request/header', - data: { header: { config: { provider: 'prior' } } }, - }, - { type: 'turn/end', data: { turn: 1 } }, - { type: 'turn/start', data: { turn: 2 } }, - { type: 'step/end', data: { turn: 2, step: 1 } }, - ] as never, 2, 1)).toBe('prior') - }) - - it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { + it('accepts bounded and unbounded records after successive closed steps', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-valid') + expect(() => { - session.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 500, failure, - }) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) session.append('step/start', { turn: 1, step: 2 }) session.append('step/end', { turn: 1, step: 2 }) session.append('llm/retry', { - turn: 1, step: 2, ...normal, retry: 2, maxRetries: 2, delayMs: 1_000, failure, - }) - const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') - zeroDelay.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(1), - retry: 1, maxRetries: 1, delayMs: 0, failure, + turn: 1, step: 2, ...normal, retry: 2, delayMs: 0, }) + const unbounded = closeStep(ctx, 'retry-invariant-always') + unbounded.append('llm/retry', { turn: 1, step: 1, ...always }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) - it('accepts unbounded always records without serializing an infinite maximum', async () => { - const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-always') - expect(() => { - session.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 500, - failure, - }) - }).not.toThrow() - expect(() => { - session.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - maxRetries: 2, - delayMs: 500, - failure, - } as never) - }).toThrow(/always mode must omit maxRetries/) - }) - - it('validates complete durable failures before either retry mode uses them', async () => { + it('validates the complete durable failure payload', async () => { const ctx = await setup() const complete = closeStep(ctx, 'retry-invariant-complete-failure') expect(() => { complete.append('llm/retry', { turn: 1, step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, + ...always, failure: { message: 'provider busy', code: 'RATE_LIMIT', @@ -128,16 +90,8 @@ describe('llm-retry invariants', () => { }) }).not.toThrow() - const normalNull = closeStep(ctx, 'retry-invariant-normal-null-failure') - expect(() => { - normalNull.append('llm/retry', { - turn: 1, step: 1, ...normal, - retry: 1, maxRetries: 2, delayMs: 1, failure: null, - } as never) - }).toThrow(/failure must be an object/) - const invalidFailures: readonly [string, unknown, RegExp][] = [ - ['always-null', null, /failure must be an object/], + ['null', null, /failure must be an object/], ['message-type', { message: 1, code: 'RATE_LIMIT' }, /failure\.message/], ['message-empty', { message: '', code: 'RATE_LIMIT' }, /failure\.message/], ['code-type', { message: 'failed', code: 1 }, /failure\.code/], @@ -159,294 +113,124 @@ describe('llm-retry invariants', () => { ['request-id-empty', { message: 'failed', code: 'RATE_LIMIT', requestId: '' }, /failure\.requestId/], ] for (const [name, invalidFailure, message] of invalidFailures) { - const session = closeStep(ctx, `retry-invariant-${name}`) + const session = closeStep(ctx, `retry-invariant-failure-${name}`) expect(() => { session.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure: invalidFailure, + turn: 1, step: 1, ...always, failure: invalidFailure, } as never) }).toThrow(message) } }) - it('binds event mode and finite budget to the canonical policy key', async () => { - const ctx = await setup() - const normalModeMismatch = closeStep(ctx, 'retry-invariant-normal-mode-key') - expect(() => { - normalModeMismatch.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: alwaysPolicyKey, - retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - }).toThrow(/mode normal must match policyKey mode always/) - - const alwaysModeMismatch = closeStep(ctx, 'retry-invariant-always-mode-key') - expect(() => { - alwaysModeMismatch.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: normalPolicyKey(2), - retry: 1, - delayMs: 1, - failure, - }) - }).toThrow(/mode always must match policyKey mode normal/) - - const budgetMismatch = closeStep(ctx, 'retry-invariant-budget-key') - expect(() => { - budgetMismatch.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), - retry: 1, maxRetries: 2, delayMs: 1, failure, - }) - }).toThrow(/maxRetries 2 must match policyKey/) - }) - - it('binds the failure code and scheduled delay to the canonical policy key', async () => { - const ctx = await setup() - const ineligibleFailure = closeStep(ctx, 'retry-invariant-failure-code-key') - expect(() => { - ineligibleFailure.append('llm/retry', { - turn: 1, step: 1, ...normal, - retry: 1, maxRetries: 2, delayMs: 1, - failure: { message: 'authentication failed', code: 'AUTH', status: 401 }, - }) - }).toThrow(/failure code AUTH must be eligible under policyKey/) - - const overPolicyDelay = closeStep(ctx, 'retry-invariant-delay-key') - expect(() => { - overPolicyDelay.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: '["always",1,1,0]', - retry: 1, - delayMs: 2, - failure, - }) - }).toThrow(/within policyKey range 0\.\.1/) - }) - - it('rejects empty providers and unknown modes from hostile durable input', async () => { - const ctx = await setup() - const emptyProvider = closeStep(ctx, 'retry-invariant-empty-provider') - expect(() => { - emptyProvider.append('llm/retry', { - turn: 1, - step: 1, - provider: '', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure, - }) - }).toThrow(/provider must be non-empty/) - - const unknownMode = closeStep(ctx, 'retry-invariant-unknown-mode') - expect(() => { - unknownMode.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'sometimes', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure, - } as never) - }).toThrow(/mode must be normal or always/) - - const emptyPolicyKey = closeStep(ctx, 'retry-invariant-empty-policy-key') - expect(() => { - emptyPolicyKey.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: '', - retry: 1, - delayMs: 1, - failure, - }) - }).toThrow(/policyKey must encode a canonical resolved policy/) - }) - it.each([ - [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], - [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], - [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], - [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], - [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], - [{ retry: 1, maxRetries: 2, delayMs: -1 }, /delayMs/], - [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], - ])('rejects invalid retry bounds %#', async (data, message) => { + ['retry-zero', { ...normal, retry: 0 }, /positive safe integer/], + ['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/], + ['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/], + ['max-fraction', { ...normal, maxRetries: 1.5 }, /positive safe maxRetries/], + ['over-budget', { ...normal, retry: 3 }, /must not exceed/], + ['always-maximum', { ...always, maxRetries: 2 }, /always mode must omit maxRetries/], + ['unknown-mode', { ...always, mode: 'sometimes' }, /mode must be normal or always/], + ['empty-provider', { ...always, provider: '' }, /provider must be a non-empty string/], + ['empty-policy-key', { ...always, policyKey: '' }, /policyKey must be a non-empty string/], + ['delay-negative', { ...normal, delayMs: -1 }, /delayMs/], + ['delay-overflow', { ...normal, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], + ['delay-type', { ...normal, delayMs: '1' }, /delayMs/], + ])('rejects invalid retry data: %s', async (name, data, message) => { const ctx = await setup() - const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) + const session = closeStep(ctx, `retry-invariant-${name}`) expect(() => { - session.append('llm/retry', { turn: 1, step: 1, ...normal, ...data, failure }) + session.append('llm/retry', { turn: 1, step: 1, ...data } as never) }).toThrow(message) }) - it('rejects retry records outside the matching closed-step boundary', async () => { + it('rejects records outside the latest closed step of an open turn', async () => { const ctx = await setup() const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) expect(() => { - absent.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + absent.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/inside an open turn/) const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') expect(() => { - wrongTurn.append('llm/retry', { - turn: 2, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + wrongTurn.append('llm/retry', { turn: 2, step: 1, ...normal }) }).toThrow(/open turn is 1/) const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) openStep.append('step/start', { turn: 1, step: 1 }) expect(() => { - openStep.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + openStep.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/step 1 is still open/) const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => { - noStep.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + noStep.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/latest closed step is undefined/) const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') expect(() => { - wrongStep.append('llm/retry', { - turn: 1, step: 2, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + wrongStep.append('llm/retry', { turn: 1, step: 2, ...normal }) }).toThrow(/latest closed step is 1/) const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(() => { - closedTurn.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + closedTurn.append('llm/retry', { turn: 1, step: 1, ...normal }) }).toThrow(/inside an open turn/) }) - it('binds the policy provider to the failed step rather than a later header', async () => { + it('rejects a second retry record for the same step', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-duplicate') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...normal, retry: 2 }) + }).toThrow(/duplicates the retry record/) + }) + + it('binds retry numbering to the provider policy and resets it after success', async () => { + const ctx = await setup() + const mismatch = closeStep(ctx, 'retry-invariant-numbering') + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) + mismatch.append('step/start', { turn: 1, step: 2 }) + mismatch.append('step/end', { turn: 1, step: 2 }) + expect(() => { + mismatch.append('llm/retry', { turn: 1, step: 2, ...normal, retry: 1 }) + }).toThrow(/must equal provider policy retry 2/) + + const reset = closeStep(ctx, 'retry-invariant-reset') + reset.append('llm/retry', { turn: 1, step: 1, ...normal }) + reset.append('step/start', { turn: 1, step: 2 }) + reset.append('assistant/message', { + turn: 1, + step: 2, + content: [{ type: 'text', text: 'success' }], + provenance: { provider: 'mock', model: 'mock' }, + }, { surfaceOp: 'append' }) + reset.append('step/end', { turn: 1, step: 2 }) + reset.append('step/start', { turn: 1, step: 3 }) + reset.append('step/end', { turn: 1, step: 3 }) + expect(() => { + reset.append('llm/retry', { turn: 1, step: 3, ...normal }) + }).not.toThrow() + }) + + it('rejects a provider that does not match the failed request route', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-provider') - session.append('request/header', { - header: { config: { provider: 'other', model: 'mock' } }, - reason: 'change', - }) expect(() => { - session.append('llm/retry', { - turn: 1, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure, - }) - }).not.toThrow() - - const mismatch = closeStep(ctx, 'retry-invariant-provider-mismatch') - expect(() => { - mismatch.append('llm/retry', { - turn: 1, - step: 1, - provider: 'other', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure, - }) + session.append('llm/retry', { turn: 1, step: 1, ...always, provider: 'other' }) }).toThrow(/does not match the failed request provider mock/) }) - it('accepts a current-turn retry under an unchanged prior provider route', async () => { - const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-prior-route') - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 2, step: 1 }) - session.append('step/end', { turn: 2, step: 1 }) - expect(() => { - session.append('llm/retry', { - turn: 2, - step: 1, - provider: 'mock', - mode: 'always', - policyKey: alwaysPolicyKey, - retry: 1, - delayMs: 1, - failure, - }) - }).not.toThrow() - }) - - it('rejects non-numeric durable delays', async () => { - const ctx = await setup() - const session = closeStep(ctx, 'retry-invariant-delay-type') - expect(() => { - session.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: '1', failure, - } as never) - }).toThrow(/delayMs must be a finite number/) - }) - - it('rejects duplicate and non-increasing retry records', async () => { - const ctx = await setup() - const duplicate = closeStep(ctx, 'retry-invariant-duplicate') - duplicate.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), - retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - expect(() => { - duplicate.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), - retry: 2, maxRetries: 3, delayMs: 1, failure, - }) - }).toThrow(/duplicates the retry record/) - - const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') - nonIncreasing.append('llm/retry', { - turn: 1, step: 1, ...normal, policyKey: normalPolicyKey(3), - retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - nonIncreasing.append('step/start', { turn: 1, step: 2 }) - nonIncreasing.append('step/end', { turn: 1, step: 2 }) - expect(() => { - nonIncreasing.append('llm/retry', { - turn: 1, step: 2, ...normal, policyKey: normalPolicyKey(3), - retry: 1, maxRetries: 3, delayMs: 1, failure, - }) - }).toThrow(/must equal provider policy retry 2/) - }) - it('validates existing histories on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('retry-invariant-late')) session.append('step/end', { turn: 1, step: 1 }) - session.append('llm/retry', { - turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, - }) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) }) diff --git a/packages/llm/llm-retry/tests/policy-key.spec.ts b/packages/llm/llm-retry/tests/policy-key.spec.ts deleted file mode 100644 index 99c7da7802..0000000000 --- a/packages/llm/llm-retry/tests/policy-key.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { parseRetryPolicyKey, retryPolicyKey } from '../src/policy-key.ts' - -describe('retry policy durable key', () => { - it('includes every policy field while normalizing code-set order', () => { - const first = resolveRetryPolicy({ - mode: 'normal', - maxRetries: 4, - retryableCodes: ['SERVER', 'RATE_LIMIT'], - backoff: { initialDelayMs: 3, maxDelayMs: 9, jitterRatio: 0.25 }, - }, 'first') - const reordered = resolveRetryPolicy({ - mode: 'normal', - maxRetries: 4, - retryableCodes: ['RATE_LIMIT', 'SERVER'], - backoff: { initialDelayMs: 3, maxDelayMs: 9, jitterRatio: 0.25 }, - }, 'reordered') - const key = retryPolicyKey(first) - - expect(key).toBe('["normal",4,["RATE_LIMIT","SERVER"],3,9,0.25]') - expect(retryPolicyKey(reordered)).toBe(key) - expect(parseRetryPolicyKey(key)).toEqual(reordered) - }) - - it('round-trips always mode', () => { - const policy = resolveRetryPolicy({ - mode: 'always', - backoff: { initialDelayMs: 2, maxDelayMs: 8, jitterRatio: 1 }, - }, 'always') - const key = retryPolicyKey(policy) - - expect(key).toBe('["always",2,8,1]') - expect(parseRetryPolicyKey(key)).toEqual(policy) - }) - - it.each([ - undefined, - '', - '{', - '{}', - '["sometimes",1,2,0]', - '["always",1,2]', - '["always","1",2,0]', - '["always",1e400,2,0]', - '["always",0,2,0]', - `["always",${MAX_TIMER_DELAY_MS + 1},${MAX_TIMER_DELAY_MS + 1},0]`, - '["always",1,"2",0]', - '["always",1,1e400,0]', - '["always",1,0,0]', - `["always",1,${MAX_TIMER_DELAY_MS + 1},0]`, - '["always",2,1,0]', - '["always",1,2,"0"]', - '["always",1,2,1e400]', - '["always",1,2,-0.1]', - '["always",1,2,1.1]', - '["normal",2,["SERVER"],1,2]', - '["normal","2",["SERVER"],1,2,0]', - '["normal",-1,["SERVER"],1,2,0]', - '["normal",2,"SERVER",1,2,0]', - '["normal",2,[],1,2,0]', - '["normal",2,[1],1,2,0]', - '["normal",2,[""],1,2,0]', - '["normal",2,["SERVER","SERVER"],1,2,0]', - '["normal",2,["SERVER"],2,1,0]', - '["normal",2,["SERVER","RATE_LIMIT"],1,2,0]', - ])('rejects non-canonical durable input %#', (value) => { - expect(parseRetryPolicyKey(value)).toBeUndefined() - }) -}) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 303c94efa4..7ed040f06a 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -181,19 +181,14 @@ describe('provider-routed retry policy', () => { new LlmError('busy', 'RATE_LIMIT', { status: 429 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter, {}, undefined, { random: () => 0.5 })) + ;({ ctx: context } = await harness(adapter, { + mock: normalConfig({ retryableCodes: ['SERVER', 'RATE_LIMIT'] }), + }, undefined, { random: () => 0.5 })) const agent = context.agentLoop.create(SessionId('retry-success'), { provider: 'mock', model: 'mock', }) - const scheduled = new Promise>((resolve) => { - const dispose = context?.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'llm/retry') { - dispose?.() - resolve(event) - } - }) - }) + const scheduled = waitForRetry(context, agent, 1) agent.followup([{ type: 'text', text: 'go' }]) const event = await scheduled @@ -203,7 +198,7 @@ describe('provider-routed retry policy', () => { step: 1, provider: 'mock', mode: 'normal', - policyKey: '["normal",2,["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"],500,10000,0.1]', + policyKey: '["normal",2,["RATE_LIMIT","SERVER"],500,10000,0]', retry: 1, maxRetries: 2, delayMs: 500, @@ -227,45 +222,6 @@ describe('provider-routed retry policy', () => { }) }) - it('retries a later turn under its unchanged provider header', async () => { - vi.useFakeTimers() - const adapter = new ScriptedAdapter([ - textResponse('first turn'), - new LlmError('busy on second turn', 'RATE_LIMIT'), - textResponse('second turn recovered'), - ]) - ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ - backoff: { initialDelayMs: 1, maxDelayMs: 1 }, - }) })) - const agent = context.agentLoop.create(SessionId('retry-later-turn'), { - provider: 'mock', - model: 'mock', - }) - - const firstIdle = waitForIdle(context, agent) - agent.followup([{ type: 'text', text: 'first' }]) - await firstIdle - expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) - - const scheduled = waitForRetry(context, agent, 1) - agent.followup([{ type: 'text', text: 'second' }]) - expect((await scheduled).data).toMatchObject({ - turn: 2, - step: 1, - provider: 'mock', - }) - const secondIdle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(1) - await secondIdle - - expect(adapter.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) - expect(agent.session.deriveMessages().at(-1)).toMatchObject({ - role: 'assistant', - content: [{ type: 'text', text: 'second turn recovered' }], - }) - }) - it('retries an EMPTY_RESPONSE error finish under the default retryable codes', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ @@ -556,8 +512,50 @@ describe('provider-routed retry policy', () => { expect(adapter.requests.map(request => request.provider)).toEqual(['other', 'other']) }) + it('keeps finite retry budgets scoped to the failed provider', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('mock failed', 'SERVER'), + new LlmError('other failed', 'SERVER'), + textResponse('other recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { + mock: normalConfig({ + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }), + other: normalConfig({ + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }), + }, (ctx) => { + ctx.on('agent/request', async (_agent, _turn, step, config) => ({ + ...config, + provider: step === 1 ? 'mock' : 'other', + })) + })) + const agent = context.agentLoop.create(SessionId('retry-provider-budgets'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.followup([{ type: 'text', text: 'switch provider after failure' }]) + await vi.runAllTimersAsync() + await idle + + expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + provider: event.data.provider, + retry: event.data.retry, + }))).toEqual([ + { provider: 'mock', retry: 1 }, + { provider: 'other', retry: 1 }, + ]) + }) + it.each(['thrown', 'in-band'] as const)( - 'uses the serving registration policy when an in-flight route is replaced after a %s failure', + 'uses the serving registration policy and resets changed-policy history after a %s failure', async (failureKind) => { vi.useFakeTimers() const entered = Promise.withResolvers() @@ -590,23 +588,40 @@ describe('provider-routed retry policy', () => { await entered.promise mounted.disposeAdapter() - const replacement = new ScriptedAdapter([textResponse('replacement recovered')]) - replacement.configureRetryPolicies({ mock: normalConfig({ maxRetries: 0 }) }) + const replacement = new ScriptedAdapter([ + new LlmError('replacement failed', 'AUTH'), + textResponse('replacement recovered'), + ]) + replacement.configureRetryPolicies({ mock: alwaysConfig({ + initialDelayMs: 3, + maxDelayMs: 3, + }) }) context.llm.registerAdapter(['mock'], replacement) release.resolve(undefined) - expect((await scheduled).data).toMatchObject({ + const firstEvent = await scheduled + expect(firstEvent.data).toMatchObject({ provider: 'mock', mode: 'always', retry: 1, delayMs: 1, }) + const replacementScheduled = waitForRetry(context, agent, 1) const idle = waitForIdle(context, agent) await vi.advanceTimersByTimeAsync(1) + const replacementEvent = await replacementScheduled + expect(replacementEvent.data).toMatchObject({ + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 3, + }) + expect(replacementEvent.data.policyKey).not.toBe(firstEvent.data.policyKey) + await vi.advanceTimersByTimeAsync(3) await idle expect(oldAdapter.requests).toHaveLength(1) - expect(replacement.requests).toHaveLength(1) + expect(replacement.requests).toHaveLength(2) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'replacement recovered' }], @@ -614,110 +629,6 @@ describe('provider-routed retry policy', () => { }, ) - it('starts a new retry history when a same-mode route replacement changes policy', async () => { - vi.useFakeTimers() - const oldAdapter = new ScriptedAdapter([ - new LlmError('old route failed', 'AUTH'), - ]) - const mounted = await harness(oldAdapter, { mock: alwaysConfig({ - initialDelayMs: 1, - maxDelayMs: 1, - }) }) - context = mounted.ctx - const agent = context.agentLoop.create(SessionId('retry-policy-replacement'), { - provider: 'mock', - model: 'mock', - }) - const first = waitForRetry(context, agent, 1) - - agent.followup([{ type: 'text', text: 'replace policy between attempts' }]) - expect((await first).data).toMatchObject({ - mode: 'always', - retry: 1, - delayMs: 1, - }) - - mounted.disposeAdapter() - const replacement = new ScriptedAdapter([ - new LlmError('replacement failed', 'AUTH'), - textResponse('replacement recovered'), - ]) - replacement.configureRetryPolicies({ mock: alwaysConfig({ - initialDelayMs: 3, - maxDelayMs: 9, - }) }) - context.llm.registerAdapter(['mock'], replacement) - - const second = waitForRetry(context, agent, 1) - await vi.advanceTimersByTimeAsync(1) - expect((await second).data).toMatchObject({ - mode: 'always', - retry: 1, - delayMs: 3, - }) - - const idle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(3) - await idle - - expect(oldAdapter.requests).toHaveLength(1) - expect(replacement.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ - policyKey: event.data.policyKey, - retry: event.data.retry, - }))).toEqual([ - { policyKey: '["always",1,1,0]', retry: 1 }, - { policyKey: '["always",3,9,0]', retry: 1 }, - ]) - }) - - it('continues retry history when a replacement only reorders retryable codes', async () => { - vi.useFakeTimers() - const oldAdapter = new ScriptedAdapter([ - new LlmError('old route failed', 'SERVER'), - ]) - const mounted = await harness(oldAdapter, { mock: normalConfig({ - maxRetries: 2, - retryableCodes: ['SERVER', 'RATE_LIMIT'], - backoff: { initialDelayMs: 1, maxDelayMs: 4 }, - }) }) - context = mounted.ctx - const agent = context.agentLoop.create(SessionId('retry-policy-code-order'), { - provider: 'mock', - model: 'mock', - }) - const first = waitForRetry(context, agent, 1) - - agent.followup([{ type: 'text', text: 'replace equivalent policy between attempts' }]) - const firstEvent = await first - expect(firstEvent.data.delayMs).toBe(1) - - mounted.disposeAdapter() - const replacement = new ScriptedAdapter([ - new LlmError('replacement failed', 'SERVER'), - textResponse('replacement recovered'), - ]) - replacement.configureRetryPolicies({ mock: normalConfig({ - maxRetries: 2, - retryableCodes: ['RATE_LIMIT', 'SERVER'], - backoff: { initialDelayMs: 1, maxDelayMs: 4 }, - }) }) - context.llm.registerAdapter(['mock'], replacement) - - const second = waitForRetry(context, agent, 2) - await vi.advanceTimersByTimeAsync(1) - const secondEvent = await second - expect(secondEvent.data).toMatchObject({ retry: 2, delayMs: 2 }) - expect(secondEvent.data.policyKey).toBe(firstEvent.data.policyKey) - - const idle = waitForIdle(context, agent) - await vi.advanceTimersByTimeAsync(2) - await idle - - expect(oldAdapter.requests).toHaveLength(1) - expect(replacement.requests).toHaveLength(2) - }) - it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ From a7a369a7beee9720d7c7ce4097c7d0804b685f5d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 13:25:45 +0800 Subject: [PATCH 11/41] chore(llm): bump pi-ai to 0.82.1 --- packages/llm/llm-pi-ai/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 590e49f323..e2b639624b 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -33,7 +33,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "@earendil-works/pi-ai": "^0.81.1", + "@earendil-works/pi-ai": "^0.82.1", "schemastery": "^3.18.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..ae9aa29137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2644,8 +2644,8 @@ importers: packages/llm/llm-pi-ai: dependencies: '@earendil-works/pi-ai': - specifier: ^0.81.1 - version: 0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + specifier: ^0.82.1 + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -5589,8 +5589,8 @@ packages: search-insights: optional: true - '@earendil-works/pi-ai@0.81.1': - resolution: {integrity: sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ==} + '@earendil-works/pi-ai@0.82.1': + resolution: {integrity: sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==} engines: {node: '>=22.19.0'} hasBin: true @@ -10711,7 +10711,7 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.81.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8da07afcf0..9141002a82 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,4 +53,4 @@ minimumReleaseAgeExclude: - cordis@4.0.0-rc.7 # 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' + - '@earendil-works/pi-ai@0.82.1' From b4a130448976f18c3f7aeac0427562709fbba08a Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 27 Jul 2026 16:57:26 +0800 Subject: [PATCH 12/41] 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 13/41] 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 14/41] 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 15/41] 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 16/41] 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 17/41] 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 18/41] 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 19/41] 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 20/41] 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 21/41] 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 22/41] 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 3a69e7a2d874185bdd4530fd95e52662543ae9ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:02:29 +0800 Subject: [PATCH 23/41] fix(dev-infra): isolate Lefthook per worktree --- .../2026-07-27-worktree-local-lefthook.md | 39 +++ docs/development.md | 8 +- lefthook.yml | 2 +- scripts/install-lefthook.mjs | 293 ++++++++++++++++- scripts/install-lefthook.spec.ts | 305 ++++++++++++++++++ 5 files changed, 627 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md create mode 100644 scripts/install-lefthook.spec.ts diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md new file mode 100644 index 0000000000..04db203037 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -0,0 +1,39 @@ +# Agent Note: Make Lefthook installation worktree-local + +Status: implemented + +English | [中文](2026-07-27-worktree-local-lefthook.zh.md) + +## Problem + +Every `pnpm install` runs the root [`postinstall`](../../../../package.json), whose [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) invokes `lefthook install --force`. Linked Git worktrees otherwise share the common repository's default hooks directory, so an install in any worktree can rewrite hooks used by every other worktree. + +Lefthook-generated hooks prefer an absolute binary path captured from the installing worktree before trying their current-worktree fallback. Shared hooks can therefore run another worktree's pinned binary until that worktree disappears, while concurrent installs write the same files. + +## Decision + +Hook installation is worktree-scoped. The installer requires Git 2.20 or newer, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. + +The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It refuses an unowned directory or a worktree-specific custom `core.hooksPath`. An inherited global or common-repository hook path is preserved by default; `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` explicitly lets only the current worktree override it, so worktrees without that override continue using the inherited path. This opt-in does not attempt to chain arbitrary hook managers. + +Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true` is refused for manual migration. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. + +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, the Git version boundary, custom-path refusal and opt-in, legacy common-hook preservation, and failed-install rollback. + +## Alternatives considered + +**Keep the shared generated hooks and rely on their current-worktree fallback.** The captured absolute path wins while its worktree exists, so the fallback does not provide version or lifecycle isolation. + +**Point every worktree at one checked-in `.githooks` directory.** A relative tracked directory removes generated absolute paths, but changing the shared `core.hooksPath` can disable hooks in older worktrees whose branches do not contain that directory and still couples every worktree to one shared configuration value. + +**Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit. + +**Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees. + +## Consequences + +Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged. + +The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.20. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. + +Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer. diff --git a/docs/development.md b/docs/development.md index fd7f39ae7b..530a5ea886 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ This onboarding guide helps project contributors get started with the local envi - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. -- Git. +- Git 2.20 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -19,14 +19,16 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands. +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited global or repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; a worktree-specific custom path is never overwritten and must be integrated or removed explicitly. + Run typecheck once after a fresh clone: ```sh diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..4a64ea804d 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,6 @@ # Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full # repository-wide gate matrix. -# Install: `pnpm exec lefthook install` (runs automatically via postinstall). +# Install: `node scripts/install-lefthook.mjs` (runs automatically via postinstall). pre-commit: jobs: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 9256a9b462..3dc19c9dca 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,21 +1,282 @@ #!/usr/bin/env node -import { existsSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { join } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' -const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' }) -if (git.status !== 0) process.exit(0) +const MINIMUM_GIT = [2, 20, 0] +const HOOKS_DIRECTORY = 'dsh-hooks' +const OWNERSHIP_MARKER = '.dsh-lefthook-owned' +const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' +const INSTALL_LOCK = 'dsh-lefthook-install.lock' +const INSTALL_LOCK_TIMEOUT_MS = 30_000 +const INSTALL_LOCK_POLL_MS = 50 +const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' -const isWindows = process.platform === 'win32' -const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') -if (!existsSync(lefthook)) process.exit(0) +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} -// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980) -// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns -// `EINVAL` with a null status, which would otherwise fail postinstall. Quote -// the path because a shell re-parses the command line and the path may contain -// spaces. POSIX needs no shell: the extensionless shim is directly executable. -const result = isWindows - ? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true }) - : spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' }) -process.exit(result.status ?? 1) +function commandFailure(command, args, result) { + const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '' + const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`) + return new Error(`${command} ${args.join(' ')} failed: ${detail}`) +} + +function capture(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + env: process.env, + }) + if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) { + throw commandFailure(command, args, result) + } + return result +} + +function git(args, root, options = {}) { + return capture('git', args, { ...options, cwd: root }) +} + +function nulValues(result) { + if (result.status !== 0) return [] + if (result.stdout === '') return [''] + const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout + return output.split('\0') +} + +function fileConfigValues(root, configPath, key) { + return nulValues(git( + ['config', '--file', configPath, '--null', '--get-all', key], + root, + { allowStatuses: [1] }, + )) +} + +function effectiveConfigValue(root, key) { + const values = nulValues(git( + ['config', '--null', '--get', key], + root, + { allowStatuses: [1] }, + )) + if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`) + return values[0] +} + +function parseGitBoolean(value, key) { + const normalized = value.toLowerCase() + if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true + if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false + throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`) +} + +function assertSingle(values, key) { + if (values.length > 1) throw new Error(`multiple ${key} values are not supported`) + return values[0] +} + +function assertSupportedGit(root) { + const version = git(['--version'], root).stdout.trim() + const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version) + if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`) + const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] + for (let index = 0; index < MINIMUM_GIT.length; index += 1) { + if (actual[index] > MINIMUM_GIT[index]) return + if (actual[index] < MINIMUM_GIT[index]) { + throw new Error(`Git 2.20 or newer is required for worktree-local hooks; found ${version}`) + } + } +} + +function ensureWorktreeConfig(root, commonConfigPath) { + const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') + const versionText = assertSingle(versions, 'core.repositoryFormatVersion') + const version = Number(versionText) + if (!Number.isInteger(version) || version < 0) { + throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) + } + + const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree') + if (worktrees.length > 0) { + throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first') + } + + const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare') + if (bare === true) { + throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true') + } + + const extensionText = assertSingle( + fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + 'extensions.worktreeConfig', + ) + const extensionEnabled = extensionText === undefined + ? false + : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + + if (version === 0) { + git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) + } + if (!extensionEnabled) { + git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root) + } + if (bare === false) { + git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root) + } +} + +function lockOwnerIsAlive(lockPath) { + let owner + try { + owner = Number(readFileSync(lockPath, 'utf8').trim()) + } catch (error) { + if (errorCode(error) === 'ENOENT') return false + throw error + } + if (!Number.isSafeInteger(owner) || owner <= 0) return true + try { + process.kill(owner, 0) + return true + } catch (error) { + if (errorCode(error) === 'ESRCH') return false + if (errorCode(error) === 'EPERM') return true + throw error + } +} + +function removeStaleLock(lockPath) { + try { + unlinkSync(lockPath) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + // Another waiting installer removed the same stale lock first. + } +} + +async function acquireInstallLock(commonDirectory) { + const lockPath = join(commonDirectory, INSTALL_LOCK) + const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS + while (true) { + try { + writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 }) + return () => removeStaleLock(lockPath) + } catch (error) { + if (errorCode(error) !== 'EEXIST') throw error + if (!lockOwnerIsAlive(lockPath)) { + removeStaleLock(lockPath) + continue + } + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) + } + await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS)) + } + } +} + +function ensureOwnedHooksDirectory(hooksPath) { + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + if (!existsSync(hooksPath)) { + mkdirSync(hooksPath, { mode: 0o700 }) + writeFileSync(markerPath, OWNERSHIP_MARKER_CONTENT, { flag: 'wx', mode: 0o600 }) + return + } + const hooksStat = lstatSync(hooksPath) + if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) { + throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`) + } + if (!existsSync(markerPath)) { + throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`) + } + const markerStat = lstatSync(markerPath) + if (!markerStat.isFile() || markerStat.isSymbolicLink() || readFileSync(markerPath, 'utf8') !== OWNERSHIP_MARKER_CONTENT) { + throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) + } +} + +function runLefthook(root, lefthook) { + const args = ['install', '--force'] + // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is + // re-parsed by cmd.exe, while POSIX can execute its extensionless shim. + const result = process.platform === 'win32' + ? spawnSync(`"${lefthook}"`, args, { cwd: root, stdio: 'inherit', shell: true }) + : spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' }) + if (result.status !== 0) throw commandFailure(lefthook, args, result) +} + +function refuseCustomHooksPath(root, hooksPath) { + const origin = git( + ['config', '--show-origin', '--get', 'core.hooksPath'], + root, + { allowStatuses: [1] }, + ).stdout.trim() + const source = origin === '' ? hooksPath : origin + throw new Error( + `refusing to replace user-owned core.hooksPath (${source}). ` + + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, ` + + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`, + ) +} + +async function main() { + const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) + if (probe.status !== 0) return + const root = probe.stdout.trim() + const isWindows = process.platform === 'win32' + const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') + if (!existsSync(lefthook)) return + + assertSupportedGit(root) + const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim() + const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim() + const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput) + const commonConfigPath = join(commonDirectory, 'config') + const worktreeConfigPath = join(gitDirectory, 'config.worktree') + const hooksPath = join(gitDirectory, HOOKS_DIRECTORY) + const releaseLock = await acquireInstallLock(commonDirectory) + + try { + const worktreePath = assertSingle( + fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'), + 'worktree core.hooksPath', + ) + if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath) + + const effectivePath = effectiveConfigValue(root, 'core.hooksPath') + const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath + if ( + effectivePath !== undefined + && !effectivePathIsOwned + && process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1' + ) { + refuseCustomHooksPath(root, effectivePath) + } + + ensureOwnedHooksDirectory(hooksPath) + ensureWorktreeConfig(root, commonConfigPath) + + let pathChanged = false + try { + git(['config', '--worktree', 'core.hooksPath', hooksPath], root) + pathChanged = worktreePath === undefined + runLefthook(root, lefthook) + } catch (error) { + if (pathChanged) { + git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + } + throw error + } + } finally { + releaseLock() + } +} + +try { + await main() +} catch (error) { + console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +} diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts new file mode 100644 index 0000000000..81f1be2897 --- /dev/null +++ b/scripts/install-lefthook.spec.ts @@ -0,0 +1,305 @@ +import { spawn, spawnSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) +const fixtures: string[] = [] + +interface Fixture { + container: string + env: NodeJS.ProcessEnv + linked: string + main: string +} + +interface CommandResult { + status: number | null + stderr: string + stdout: string +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) +}) + +function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', env }) + return { status: result.status, stderr: result.stderr, stdout: result.stdout } +} + +function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult { + return commandResult('git', args, cwd, fixture.env) +} + +function git(fixture: Fixture, cwd: string, args: string[]): string { + const result = gitResult(fixture, cwd, args) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`) + } + return result.stdout.trim() +} + +function write(path: string, content: string, mode?: number): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content, mode === undefined ? undefined : { mode }) +} + +function fakeLefthookSource(): string { + return `#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' + +if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64) +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() +const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim() +mkdirSync(hooksPath, { recursive: true }) +const running = join(hooksPath, '.fake-lefthook-running') +try { + writeFileSync(running, String(process.pid), { flag: 'wx' }) +} catch { + process.exit(91) +} +const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0) +if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) +const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1' +if (!shouldFail) { + const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook') + const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim() + const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\` + for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 }) +} +if (existsSync(running)) unlinkSync(running) +if (shouldFail) process.exit(77) +` +} + +function installFakeLefthook(root: string): void { + const binDirectory = join(root, 'node_modules/.bin') + mkdirSync(binDirectory, { recursive: true }) + writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource()) + if (process.platform === 'win32') { + writeFileSync( + join(binDirectory, 'lefthook.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`, + ) + return + } + const shim = join(binDirectory, 'lefthook') + writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`) + chmodSync(shim, 0o755) +} + +function createFixture(): Fixture { + const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-')) + fixtures.push(container) + const main = join(container, 'main') + const linked = join(container, 'linked') + const env: NodeJS.ProcessEnv = { + ...process.env, + GIT_AUTHOR_EMAIL: 'hooks@example.test', + GIT_AUTHOR_NAME: 'Hooks Test', + GIT_COMMITTER_EMAIL: 'hooks@example.test', + GIT_COMMITTER_NAME: 'Hooks Test', + GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'), + GIT_CONFIG_NOSYSTEM: '1', + HOME: container, + XDG_CONFIG_HOME: join(container, '.config'), + } + const fixture = { container, env, linked, main } + mkdirSync(main) + git(fixture, container, ['init', main]) + write(join(main, 'README.md'), '# fixture\n') + git(fixture, main, ['add', 'README.md']) + git(fixture, main, ['commit', '-m', 'fixture']) + git(fixture, main, ['worktree', 'add', '-b', 'linked', linked]) + write(join(main, 'lefthook.yml'), 'main-worktree-config\n') + write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n') + installFakeLefthook(main) + installFakeLefthook(linked) + return fixture +} + +function gitDirectory(fixture: Fixture, root: string): string { + return git(fixture, root, ['rev-parse', '--absolute-git-dir']) +} + +function commonDirectory(fixture: Fixture): string { + const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir']) + return isAbsolute(output) ? output : resolve(fixture.main, output) +} + +function hooksPath(fixture: Fixture, root: string): string { + return join(gitDirectory(fixture, root), 'dsh-hooks') +} + +function runInstaller( + fixture: Fixture, + root: string, + extraEnv: NodeJS.ProcessEnv = {}, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [installer], { + cwd: root, + env: { ...fixture.env, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + child.on('error', reject) + child.on('close', (status) => { resolveResult({ status, stderr, stdout }) }) + }) +} + +describe('worktree-local Lefthook installer', () => { + it('isolates main and linked worktrees without changing legacy common hooks', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-commit') + write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755) + + const mainInstall = await runInstaller(fixture, fixture.main) + const linkedInstall = await runInstaller(fixture, fixture.linked) + expect(mainInstall.status, mainInstall.stderr).toBe(0) + expect(linkedInstall.status, linkedInstall.stderr).toBe(0) + + const mainHooks = hooksPath(fixture, fixture.main) + const linkedHooks = hooksPath(fixture, fixture.linked) + expect(mainHooks).not.toBe(linkedHooks) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks) + + const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8') + const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel']) + const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel']) + expect(mainHook).toContain(`# root=${canonicalMain}`) + expect(mainHook).toContain('# config=main-worktree-config') + expect(mainHook).not.toContain(canonicalLinked) + expect(linkedHook).toContain(`# root=${canonicalLinked}`) + expect(linkedHook).toContain('# config=linked-worktree-config') + expect(linkedHook).not.toContain(canonicalMain) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + + const commonConfig = join(common, 'config') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1') + expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true') + expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) + + const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) + expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') + }) + + it('serializes concurrent installs and keeps repeated output stable', async () => { + const fixture = createFixture() + const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' } + const first = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.linked, delayed), + ]) + for (const result of first) expect(result.status, result.stderr).toBe(0) + + const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push') + const initialHook = readFileSync(mainHookPath, 'utf8') + const repeated = await Promise.all([ + runInstaller(fixture, fixture.main, delayed), + runInstaller(fixture, fixture.main, delayed), + ]) + for (const result of repeated) expect(result.status, result.stderr).toBe(0) + expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook) + expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false) + expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) + }) + + it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => { + const fixture = createFixture() + const customHook = join(fixture.main, 'custom-hooks/pre-commit') + write(customHook, '#!/bin/sh\n# custom hook\n', 0o755) + git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks']) + + const refused = await runInstaller(fixture, fixture.main) + expect(refused.status).toBe(1) + expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath') + expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + + const optedIn = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(optedIn.status, optedIn.stderr).toBe(0) + expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main)) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks') + expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n') + + git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks']) + const explicitWorktreePath = await runInstaller(fixture, fixture.linked, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(explicitWorktreePath.status).toBe(1) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') + }) + + it('restores the previous hook lookup when Lefthook installation fails', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const legacyHook = join(common, 'hooks/pre-push') + write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('exit status 77') + expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n') + }) + + it('refuses an unowned directory at the reserved worktree hook path', async () => { + const fixture = createFixture() + const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit') + write(reservedHook, '#!/bin/sh\n# user content\n', 0o755) + + const result = await runInstaller(fixture, fixture.main) + expect(result.status).toBe(1) + expect(result.stderr).toContain('refusing to overwrite unowned hooks directory') + expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => { + const fixture = createFixture() + const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim() + const fakeBin = join(fixture.container, 'fake-bin') + const fakeGit = join(fakeBin, 'git') + write( + fakeGit, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, + 0o755, + ) + + const result = await runInstaller(fixture, fixture.main, { + PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`, + }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Git 2.20 or newer is required') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) +}) From 3feaef6ecbab64c52507621a19e730b373d8c847 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:58:19 +0800 Subject: [PATCH 24/41] fix(dev-infra): harden worktree hook migration --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 6 + .../2026-07-27-worktree-local-lefthook.md | 10 +- .../2026-07-27-worktree-local-lefthook.zh.md | 39 ++ docs/development.i18n.yaml | 4 +- docs/development.md | 6 +- docs/development.zh.md | 8 +- scripts/install-lefthook.mjs | 459 +++++++++++++++--- scripts/install-lefthook.spec.ts | 295 ++++++++++- 8 files changed, 749 insertions(+), 78 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml new file mode 100644 index 0000000000..301bf88490 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md +2026-07-27-worktree-local-lefthook.md: 95860efca5309464e82d6d58c8320b3a390ae14f +2026-07-27-worktree-local-lefthook.zh.md: ea1639d2e45c61ba6b041eb5800f2b983a271e4b diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 04db203037..95860efca5 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. The installer requires Git 2.20 or newer, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. +Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It refuses an unowned directory or a worktree-specific custom `core.hooksPath`. An inherited global or common-repository hook path is preserved by default; `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` explicitly lets only the current worktree override it, so worktrees without that override continue using the inherited path. This opt-in does not attempt to chain arbitrary hook managers. +The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. -Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true` is refused for manual migration. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, the Git version boundary, custom-path refusal and opt-in, legacy common-hook preservation, and failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. ## Alternatives considered @@ -34,6 +34,6 @@ Enabling worktree config removes the standard redundant `core.bare=false` value Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged. -The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.20. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. +The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.26. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md new file mode 100644 index 0000000000..ea1639d2e4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 让 Lefthook 安装限定于各 worktree + +Status: implemented + +[English](2026-07-27-worktree-local-lefthook.md) | 中文 + +## 问题 + +每次运行 `pnpm install` 都会执行根目录的 [`postinstall`](../../../../package.json),其中的 [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) 会调用 `lefthook install --force`。若无额外配置,关联的 Git worktree 共用同一仓库的默认钩子目录,因此在任一 worktree 中安装都可能改写其他所有 worktree 使用的钩子。 + +Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝对二进制文件路径,之后才尝试当前 worktree 的回退路径。因此,共享钩子会一直运行另一个 worktree 固定版本的二进制文件,直到该 worktree 消失;并发安装还会写入同一组文件。 + +## 决策 + +钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 + +安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 + +启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 + +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 + +## 考虑过的替代方案 + +**保留共享的生成钩子,并依赖其当前 worktree 回退路径。** 只要对应 worktree 仍存在,记录的绝对路径就会优先生效,因此回退路径无法提供版本或生命周期隔离。 + +**让每个 worktree 都指向同一个纳入版本控制的 `.githooks` 目录。** 使用受版本控制的相对目录可以消除生成的绝对路径,但更改共享的 `core.hooksPath` 可能会禁用旧 worktree 中的钩子,因为其分支并不包含该目录;同时,每个 worktree 仍然耦合于同一个共享配置值。 + +**构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 + +**停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 + +## 后果 + +安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。 + +首次安装后,仓库会采用 Git 格式版本 1,并拒绝版本低于 Git 2.26 的客户端。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 + +旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8d19bd9880..5046d75245 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f -development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780 +development.md: 6c927c46b0a25f84295796354e7f49eb9eb7b2e9 +development.zh.md: f29d08df18ca9bead7f4c9bf3cf7f749630b1b84 diff --git a/docs/development.md b/docs/development.md index 530a5ea886..6c927c46b0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ This onboarding guide helps project contributors get started with the local envi - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. -- Git 2.20 or newer; hook setup enables Git's worktree-specific configuration extension. +- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: @@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited global or repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; a worktree-specific custom path is never overwritten and must be integrated or removed explicitly. +The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 7dd6209bad..f29d08df18 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -8,7 +8,7 @@ - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 -- Git。 +- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。 - 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 ## 首次搭建 @@ -19,14 +19,16 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 + 新克隆后请先运行一次类型检查: ```sh diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 3dc19c9dca..6dd88e5ca8 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node +import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { isAbsolute, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' -const MINIMUM_GIT = [2, 20, 0] +const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' @@ -11,6 +12,7 @@ const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' +const CONDITIONAL_INCLUDE_PATTERN = '^includeif\\..*\\.path$' function errorCode(error) { return typeof error === 'object' && error !== null && 'code' in error @@ -47,6 +49,13 @@ function nulValues(result) { return output.split('\0') } +function stripGitLineTerminator(output) { + const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output + return process.platform === 'win32' && withoutLineFeed.endsWith('\r') + ? withoutLineFeed.slice(0, -1) + : withoutLineFeed +} + function fileConfigValues(root, configPath, key) { return nulValues(git( ['config', '--file', configPath, '--null', '--get-all', key], @@ -55,14 +64,76 @@ function fileConfigValues(root, configPath, key) { )) } -function effectiveConfigValue(root, key) { - const values = nulValues(git( - ['config', '--null', '--get', key], +function fileConfigEntries(root, configPath, key) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], root, { allowStatuses: [1] }, )) - if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`) - return values[0] + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid file entries for ${key}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], value: fields[index + 1] }) + } + return entries +} + +function splitConfigNameValue(field, pattern) { + const separator = field.indexOf('\n') + if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`) + return { name: field.slice(0, separator), value: field.slice(separator + 1) } +} + +function fileConfigMatchingEntries(root, configPath, pattern) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern], + root, + { allowStatuses: [1] }, + )) + if (fields.length % 2 !== 0) { + throw new Error(`git config returned invalid matching file entries for ${pattern}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 2) { + entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) }) + } + return entries +} + +function scopedConfigMatchingEntries(root, pattern) { + const fields = nulValues(git( + ['config', '--includes', '--null', '--show-scope', '--show-origin', '--get-regexp', pattern], + root, + { allowStatuses: [1] }, + )) + if (fields.length % 3 !== 0) { + throw new Error(`git config returned invalid scoped entries for ${pattern}`) + } + const entries = [] + for (let index = 0; index < fields.length; index += 3) { + entries.push({ + scope: fields[index], + origin: fields[index + 1], + ...splitConfigNameValue(fields[index + 2], pattern), + }) + } + return entries +} + +function effectiveConfigEntry(root, key) { + const fields = nulValues(git( + ['config', '--null', '--show-scope', '--show-origin', '--get', key], + root, + { allowStatuses: [1] }, + )) + if (fields.length === 0) return undefined + if (fields.length !== 3) { + throw new Error(`git config returned an invalid scoped value for ${key}`) + } + const [scope, origin, value] = fields + return { origin, scope, value } } function parseGitBoolean(value, key) { @@ -85,11 +156,71 @@ function assertSupportedGit(root) { for (let index = 0; index < MINIMUM_GIT.length; index += 1) { if (actual[index] > MINIMUM_GIT[index]) return if (actual[index] < MINIMUM_GIT[index]) { - throw new Error(`Git 2.20 or newer is required for worktree-local hooks; found ${version}`) + throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`) } } } +function conditionalIncludeTarget(entry, root) { + if (isAbsolute(entry.value)) return entry.value + const sourcePath = configOriginPath(entry.origin, root) + if (sourcePath === undefined) return undefined + if (entry.value.startsWith('~/')) { + const home = process.env.HOME + return home === undefined ? undefined : resolve(home, entry.value.slice(2)) + } + if (entry.value.startsWith('~') || entry.value.startsWith('%(')) return undefined + return resolve(dirname(sourcePath), entry.value) +} + +function inspectConditionalConfig(root, configPath, inspect, seen = new Set()) { + const identity = normalizedPath(configPath) + if (seen.has(identity)) return undefined + seen.add(identity) + if (!existsSync(configPath)) { + return { configPath, detail: 'the included config does not exist and cannot be inspected' } + } + try { + const subject = inspect(configPath) + if (subject !== undefined) return { configPath, subject } + for (const entry of fileConfigMatchingEntries(root, configPath, CONDITIONAL_INCLUDE_PATTERN)) { + const target = conditionalIncludeTarget(entry, root) + if (target === undefined) { + return { configPath, detail: `the nested include path ${JSON.stringify(entry.value)} cannot be resolved safely` } + } + const nested = inspectConditionalConfig(root, target, inspect, seen) + if (nested !== undefined) return nested + } + return undefined + } catch (error) { + return { + configPath, + detail: `the included config could not be inspected: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +function conditionalIncludeRisk(root, entry, inspect) { + const target = conditionalIncludeTarget(entry, root) + if (target === undefined) { + return { detail: `the include path ${JSON.stringify(entry.value)} cannot be resolved safely` } + } + return inspectConditionalConfig(root, target, inspect) +} + +function migrationConfigSubject(root, configPath) { + const worktreeEntry = fileConfigEntries(root, configPath, 'core.worktree')[0] + if (worktreeEntry !== undefined) return `core.worktree (${configSource(worktreeEntry)})` + const trueBareEntry = fileConfigEntries(root, configPath, 'core.bare') + .find(entry => parseGitBoolean(entry.value, 'core.bare')) + return trueBareEntry === undefined ? undefined : `core.bare=true (${configSource(trueBareEntry)})` +} + +function hooksPathConfigSubject(root, configPath) { + const entry = fileConfigEntries(root, configPath, 'core.hooksPath')[0] + return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})` +} + function ensureWorktreeConfig(root, commonConfigPath) { const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') @@ -98,17 +229,6 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } - const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree') - if (worktrees.length > 0) { - throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first') - } - - const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') - const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare') - if (bare === true) { - throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true') - } - const extensionText = assertSingle( fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), 'extensions.worktreeConfig', @@ -117,26 +237,79 @@ function ensureWorktreeConfig(root, commonConfigPath) { ? false : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + if (!extensionEnabled) { + for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { + const risk = conditionalIncludeRisk( + root, + entry, + configPath => migrationConfigSubject(root, configPath), + ) + if (risk !== undefined) { + const reason = risk.subject ?? risk.detail + throw new Error( + `cannot enable extensions.worktreeConfig while common conditional include ` + + `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)} may provide migration-sensitive config (${reason}); ` + + 'audit and migrate it, then enable the extension explicitly', + ) + } + } + } + + const worktreeEntry = fileConfigEntries(root, commonConfigPath, 'core.worktree')[0] + if (worktreeEntry !== undefined) { + throw new Error( + `cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); ` + + 'move it to the main worktree config first', + ) + } + + const bareEntries = fileConfigEntries(root, commonConfigPath, 'core.bare') + const trueBareEntry = bareEntries.find(entry => parseGitBoolean(entry.value, 'core.bare')) + if (trueBareEntry !== undefined) { + throw new Error( + `cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`, + ) + } + const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + if (version === 0) { git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) } if (!extensionEnabled) { git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root) } - if (bare === false) { + if (directBare === false) { git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root) } } -function lockOwnerIsAlive(lockPath) { - let owner +function readInstallLock(lockPath) { try { - owner = Number(readFileSync(lockPath, 'utf8').trim()) + return readFileSync(lockPath, 'utf8') } catch (error) { - if (errorCode(error) === 'ENOENT') return false + if (errorCode(error) === 'ENOENT') return undefined throw error } - if (!Number.isSafeInteger(owner) || owner <= 0) return true +} + +function installLockStat(lockPath) { + try { + return lstatSync(lockPath) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function parseInstallLock(record) { + const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record) + if (match === null) return undefined + const owner = Number(match[1]) + return Number.isSafeInteger(owner) ? owner : undefined +} + +function lockOwnerIsAlive(owner) { try { process.kill(owner, 0) return true @@ -147,28 +320,63 @@ function lockOwnerIsAlive(lockPath) { } } -function removeStaleLock(lockPath) { +function manualLockRecoveryError(lockPath, condition) { + return new Error( + `${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. ` + + 'Confirm no Lefthook installer is running, remove it manually, and retry.', + ) +} + +function lockOwnershipChangedError(lockPath) { + return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`) +} + +function releaseInstallLock(lockPath, ownedRecord, ownedStat) { + const currentStat = installLockStat(lockPath) + if ( + currentStat === undefined + || !currentStat.isFile() + || currentStat.isSymbolicLink() + || currentStat.dev !== ownedStat.dev + || currentStat.ino !== ownedStat.ino + || readInstallLock(lockPath) !== ownedRecord + ) { + throw lockOwnershipChangedError(lockPath) + } try { unlinkSync(lockPath) } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - // Another waiting installer removed the same stale lock first. + if (errorCode(error) === 'ENOENT') { + throw lockOwnershipChangedError(lockPath) + } + throw error } } async function acquireInstallLock(commonDirectory) { const lockPath = join(commonDirectory, INSTALL_LOCK) const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS + const ownedRecord = `${String(process.pid)} ${randomUUID()}\n` while (true) { try { - writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 }) - return () => removeStaleLock(lockPath) + writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 }) + const ownedStat = installLockStat(lockPath) + if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) { + throw lockOwnershipChangedError(lockPath) + } + return () => releaseInstallLock(lockPath, ownedRecord, ownedStat) } catch (error) { if (errorCode(error) !== 'EEXIST') throw error - if (!lockOwnerIsAlive(lockPath)) { - removeStaleLock(lockPath) - continue + const existingStat = installLockStat(lockPath) + if (existingStat === undefined) continue + if (!existingStat.isFile() || existingStat.isSymbolicLink()) { + throw manualLockRecoveryError(lockPath, 'invalid') } + const existingRecord = readInstallLock(lockPath) + if (existingRecord === undefined) continue + const owner = parseInstallLock(existingRecord) + if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid') + if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale') if (Date.now() >= deadline) { throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`) } @@ -197,63 +405,176 @@ function ensureOwnedHooksDirectory(hooksPath) { } } +function environmentWithoutCommandGitConfig() { + const env = { ...process.env } + for (const key of Object.keys(env)) { + const normalized = key.toUpperCase() + if ( + normalized === 'GIT_CONFIG_PARAMETERS' + || normalized === 'GIT_CONFIG_COUNT' + || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized) + ) { + delete env[key] + } + } + return env +} + function runLefthook(root, lefthook) { const args = ['install', '--force'] + const env = environmentWithoutCommandGitConfig() // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is // re-parsed by cmd.exe, while POSIX can execute its extensionless shim. const result = process.platform === 'win32' - ? spawnSync(`"${lefthook}"`, args, { cwd: root, stdio: 'inherit', shell: true }) - : spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' }) + ? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true }) + : spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' }) if (result.status !== 0) throw commandFailure(lefthook, args, result) } -function refuseCustomHooksPath(root, hooksPath) { - const origin = git( - ['config', '--show-origin', '--get', 'core.hooksPath'], - root, - { allowStatuses: [1] }, - ).stdout.trim() - const source = origin === '' ? hooksPath : origin +function configSource(entry) { + return `${entry.origin}: ${JSON.stringify(entry.value)}` +} + +function normalizedPath(path) { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function configOriginPath(origin, root) { + if (!origin.startsWith('file:')) return undefined + const originPath = origin.slice('file:'.length) + return isAbsolute(originPath) ? originPath : resolve(root, originPath) +} + +function originIsFile(origin, root, configPath) { + const originPath = configOriginPath(origin, root) + return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath) +} + +function conditionalIncludeSource(entry) { + return `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)}` +} + +function conditionalIncludes(root, worktreeConfigPath) { + const entries = scopedConfigMatchingEntries(root, CONDITIONAL_INCLUDE_PATTERN) + entries.push(...fileConfigMatchingEntries(root, worktreeConfigPath, CONDITIONAL_INCLUDE_PATTERN) + .map(entry => ({ ...entry, scope: 'worktree' }))) + const unique = new Map() + for (const entry of entries) { + unique.set(`${entry.scope}\0${entry.origin}\0${entry.name}\0${entry.value}`, entry) + } + return [...unique.values()] +} + +function assertConditionalHooksPaths(root, worktreeConfigPath) { + for (const entry of conditionalIncludes(root, worktreeConfigPath)) { + const risk = conditionalIncludeRisk( + root, + entry, + configPath => hooksPathConfigSubject(root, configPath), + ) + if (risk === undefined) continue + const reason = risk.subject ?? risk.detail + if (entry.scope === 'command' || entry.scope === 'worktree') { + throw new Error( + `refusing ${entry.scope}-scoped conditional include ${conditionalIncludeSource(entry)}; ` + + `it may provide a user-owned core.hooksPath (${reason}) and cannot be overridden`, + ) + } + if (!['system', 'global', 'local'].includes(entry.scope)) { + throw new Error( + `refusing conditional include from unsupported ${entry.scope} scope ${conditionalIncludeSource(entry)}; ` + + `it may provide core.hooksPath (${reason})`, + ) + } + if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { + throw new Error( + `refusing to replace core.hooksPath that may be provided by inherited conditional include ` + + `${conditionalIncludeSource(entry)} (${reason}). Inspect that include and rerun with ` + + `${ALLOW_HOOKS_PATH_OVERRIDE}=1 only if it may remain active in other worktrees`, + ) + } + } +} + +function refuseInheritedHooksPath(entry) { throw new Error( - `refusing to replace user-owned core.hooksPath (${source}). ` + `refusing to replace user-owned core.hooksPath (${configSource(entry)}). ` + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, ` + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`, ) } +function refuseScopedHooksPath(entry) { + if (entry.scope === 'command') { + throw new Error( + `refusing to replace command-scoped core.hooksPath (${configSource(entry)}); ` + + `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`, + ) + } + if (entry.scope === 'worktree') { + throw new Error( + `refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); ` + + 'a worktree-specific custom path must be integrated or removed explicitly', + ) + } + throw new Error( + `refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`, + ) +} + async function main() { const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return - const root = probe.stdout.trim() + const root = stripGitLineTerminator(probe.stdout) const isWindows = process.platform === 'win32' const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook') if (!existsSync(lefthook)) return assertSupportedGit(root) - const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim() - const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim() + const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout) + const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout) const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput) const commonConfigPath = join(commonDirectory, 'config') const worktreeConfigPath = join(gitDirectory, 'config.worktree') const hooksPath = join(gitDirectory, HOOKS_DIRECTORY) const releaseLock = await acquireInstallLock(commonDirectory) + let installationError try { + const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') + const includedWorktreeEntry = worktreeEntries.find( + entry => !originIsFile(entry.origin, root, worktreeConfigPath), + ) + if (includedWorktreeEntry !== undefined) { + refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' }) + } const worktreePath = assertSingle( - fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'), + worktreeEntries.map(entry => entry.value), 'worktree core.hooksPath', ) - if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath) - - const effectivePath = effectiveConfigValue(root, 'core.hooksPath') - const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath - if ( - effectivePath !== undefined - && !effectivePathIsOwned - && process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1' - ) { - refuseCustomHooksPath(root, effectivePath) + if (worktreePath !== undefined && worktreePath !== hooksPath) { + refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) } + const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') + if (effectiveEntry !== undefined) { + const effectivePathIsOwned = effectiveEntry.scope === 'worktree' + && effectiveEntry.value === hooksPath + && worktreePath === hooksPath + && originIsFile(effectiveEntry.origin, root, worktreeConfigPath) + if (!effectivePathIsOwned) { + if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') { + refuseScopedHooksPath(effectiveEntry) + } + if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) { + refuseScopedHooksPath(effectiveEntry) + } + if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { + refuseInheritedHooksPath(effectiveEntry) + } + } + } + assertConditionalHooksPaths(root, worktreeConfigPath) ensureOwnedHooksDirectory(hooksPath) ensureWorktreeConfig(root, commonConfigPath) @@ -262,6 +583,15 @@ async function main() { try { git(['config', '--worktree', 'core.hooksPath', hooksPath], root) pathChanged = worktreePath === undefined + const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') + if ( + installedEntry === undefined + || installedEntry.scope !== 'worktree' + || installedEntry.value !== hooksPath + || !originIsFile(installedEntry.origin, root, worktreeConfigPath) + ) { + throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value') + } runLefthook(root, lefthook) } catch (error) { if (pathChanged) { @@ -269,8 +599,21 @@ async function main() { } throw error } + } catch (error) { + installationError = error + throw error } finally { - releaseLock() + try { + releaseLock() + } catch (releaseError) { + if (installationError !== undefined) { + throw new AggregateError( + [installationError, releaseError], + `Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`, + ) + } + throw releaseError + } } } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 81f1be2897..b54a7ab864 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -62,7 +62,17 @@ import { execFileSync } from 'node:child_process' import { join } from 'node:path' if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64) -const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() +const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) +const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput +const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY +if (forbiddenConfigKey !== undefined) { + try { + execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' }) + process.exit(92) + } catch (error) { + if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error + } +} const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim() mkdirSync(hooksPath, { recursive: true }) const running = join(hooksPath, '.fake-lefthook-running') @@ -101,11 +111,11 @@ function installFakeLefthook(root: string): void { chmodSync(shim, 0o755) } -function createFixture(): Fixture { +function createFixture(names: { main?: string; linked?: string } = {}): Fixture { const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-')) fixtures.push(container) - const main = join(container, 'main') - const linked = join(container, 'linked') + const main = join(container, names.main ?? 'main') + const linked = join(container, names.linked ?? 'linked') const env: NodeJS.ProcessEnv = { ...process.env, GIT_AUTHOR_EMAIL: 'hooks@example.test', @@ -144,6 +154,18 @@ function hooksPath(fixture: Fixture, root: string): string { return join(gitDirectory(fixture, root), 'dsh-hooks') } +function installLockPath(fixture: Fixture): string { + return join(commonDirectory(fixture), 'dsh-lefthook-install.lock') +} + +async function waitForPath(path: string): Promise { + const deadline = Date.now() + 5_000 + while (!existsSync(path)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) + await new Promise(resolveWait => setTimeout(resolveWait, 10)) + } +} + function runInstaller( fixture: Fixture, root: string, @@ -226,6 +248,69 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }) + it('leaves stale installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const completed = spawnSync(process.execPath, ['-e', '']) + expect(completed.status).toBe(0) + const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n` + writeFileSync(lockPath, staleRecord) + + const results = await Promise.all(Array.from( + { length: 4 }, + () => runInstaller(fixture, fixture.main), + )) + + for (const result of results) { + expect(result.status).toBe(1) + expect(result.stderr).toContain('stale Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + } + expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + }) + + it('leaves invalid installer locks for explicit recovery', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const invalidRecord = 'not an installer lock\n' + writeFileSync(lockPath, invalidRecord) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid Lefthook installer lock') + expect(result.stderr).toContain('remove it manually') + expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('does not release an installer lock whose ownership changed', async () => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running') + const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' }) + await waitForPath(runningPath) + const replacementRecord = 'replacement owner\n' + writeFileSync(lockPath, replacementRecord) + + const result = await install + expect(result.status).toBe(1) + expect(result.stderr).toContain('installer lock ownership changed') + expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord) + }) + + it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => { + const fixture = createFixture({ main: 'main ', linked: 'linked ' }) + + for (const root of [fixture.main, fixture.linked]) { + const result = await runInstaller(fixture, root) + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root)) + } + }) + it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => { const fixture = createFixture() const customHook = join(fixture.main, 'custom-hooks/pre-commit') @@ -257,6 +342,202 @@ describe('worktree-local Lefthook installer', () => { expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') }) + it('refuses migration keys loaded through active or conditional common-config includes', async () => { + for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { + for (const key of ['core.worktree', 'core.bare']) { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) + const value = key === 'core.worktree' ? fixture.main : 'true' + git(fixture, fixture.main, ['config', '--file', includedConfig, key, value]) + git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain(key) + expect(result.stderr).toContain(includedConfig) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(false) + } + } + }) + + it('allows a conditional common-config include unrelated to migration or hooks', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const includedConfig = join(fixture.container, 'conditional-identity.gitconfig') + git(fixture, fixture.main, ['config', '--file', includedConfig, 'user.email', 'conditional@example.test']) + git(fixture, fixture.main, [ + 'config', + '--file', + commonConfig, + 'includeIf.onbranch:conditional.path', + includedConfig, + ]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) + }) + + it('never overrides a command-scoped hook path', async () => { + const fixture = createFixture() + const commandHooks = join(fixture.container, 'command-hooks') + const sentinel = join(commandHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: commandHooks, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('command-scoped core.hooksPath') + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('never overrides a hook path behind a command-scoped conditional include', async () => { + const fixture = createFixture() + const includedConfig = join(fixture.container, 'command-conditional.gitconfig') + const includedHooks = join(fixture.container, 'command-conditional-hooks') + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'includeIf.onbranch:conditional.path', + GIT_CONFIG_VALUE_0: includedConfig, + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('command-scoped conditional include') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('does not pass unrelated command-scoped Git config to Lefthook', async () => { + const fixture = createFixture() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'dsh.testSentinel', + GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook', + }) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true) + }) + + it('never overrides a hook path included by worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const includedConfig = join(fixture.container, 'included-worktree.gitconfig') + const includedHooks = join(fixture.container, 'included-hooks') + const sentinel = join(includedHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755) + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig]) + + const result = await runInstaller(fixture, fixture.main, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('worktree-scoped core.hooksPath') + expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it('refuses an inactive conditional worktree include that can later provide a hook path', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') + const includedConfig = join(fixture.container, 'conditional-worktree.gitconfig') + const includedHooks = join(fixture.container, 'conditional-hooks') + const sentinel = join(includedHooks, 'pre-commit') + write(sentinel, '#!/bin/sh\n# conditional-worktree sentinel\n', 0o755) + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + git(fixture, fixture.main, [ + 'config', + '--file', + worktreeConfig, + 'includeIf.onbranch:conditional.path', + includedConfig, + ]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('worktree-scoped conditional include') + expect(result.stderr).toContain('includeif.onbranch:conditional.path') + expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false) + + git(fixture, fixture.linked, ['switch', '-c', 'conditional']) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) + expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# conditional-worktree sentinel\n') + }) + + it('requires opt-in for inherited conditional includes that can later provide a hook path', async () => { + for (const scope of ['local', 'global']) { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const conditionalOwner = scope === 'local' + ? commonConfig + : fixture.env.GIT_CONFIG_GLOBAL + if (conditionalOwner === undefined) throw new Error('fixture global config path is missing') + const includedConfig = join(fixture.container, `${scope}-conditional.gitconfig`) + const includedHooks = join(fixture.container, `${scope}-conditional-hooks`) + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + git(fixture, fixture.main, [ + 'config', + '--file', + conditionalOwner, + 'includeIf.onbranch:conditional.path', + includedConfig, + ]) + + const refused = await runInstaller(fixture, fixture.linked) + + expect(refused.status).toBe(1) + expect(refused.stderr).toContain('inherited conditional include') + expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') + expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) + + const optedIn = await runInstaller(fixture, fixture.linked, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(optedIn.status, optedIn.stderr).toBe(0) + + git(fixture, fixture.linked, ['switch', '-c', 'conditional']) + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) + + const repeatedRefusal = await runInstaller(fixture, fixture.linked) + expect(repeatedRefusal.status).toBe(1) + expect(repeatedRefusal.stderr).toContain('inherited conditional include') + expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) + + const repeatedOptIn = await runInstaller(fixture, fixture.linked, { + DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', + }) + expect(repeatedOptIn.status, repeatedOptIn.stderr).toBe(0) + } + }) + it('restores the previous hook lookup when Lefthook installation fails', async () => { const fixture = createFixture() const common = commonDirectory(fixture) @@ -283,14 +564,14 @@ describe('worktree-local Lefthook installer', () => { expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) }) - it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => { + it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => { const fixture = createFixture() const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim() const fakeBin = join(fixture.container, 'fake-bin') const fakeGit = join(fakeBin, 'git') write( fakeGit, - `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, + `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`, 0o755, ) @@ -298,7 +579,7 @@ describe('worktree-local Lefthook installer', () => { PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`, }) expect(result.status).toBe(1) - expect(result.stderr).toContain('Git 2.20 or newer is required') + expect(result.stderr).toContain('Git 2.26 or newer is required') expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) From 46bbbce109c59437cdc47da4b92135c2af8552b3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 20:49:44 +0800 Subject: [PATCH 25/41] 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 26/41] 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 27/41] 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 19b879496602309077a78342a421db70f1966bca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:31:12 +0800 Subject: [PATCH 28/41] fix(dev-infra): guard dormant worktree configs --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 4 +- .../2026-07-27-worktree-local-lefthook.zh.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 67 ++++++++++++++++--- scripts/install-lefthook.spec.ts | 18 +++++ 8 files changed, 87 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index 301bf88490..3ca9da8947 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: 95860efca5309464e82d6d58c8320b3a390ae14f -2026-07-27-worktree-local-lefthook.zh.md: ea1639d2e45c61ba6b041eb5800f2b983a271e4b +2026-07-27-worktree-local-lefthook.md: 2e15f2d242cbcdd56452f3c8ac59bc1e330bc7c0 +2026-07-27-worktree-local-lefthook.zh.md: 742d48511f6fd46202ac9c0ead476105d9b97370 diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 95860efca5..2e15f2d242 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index ea1639d2e4..742d48511f 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 ## 考虑过的替代方案 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 5046d75245..f21ed31162 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: 6c927c46b0a25f84295796354e7f49eb9eb7b2e9 -development.zh.md: f29d08df18ca9bead7f4c9bf3cf7f749630b1b84 +development.md: 16a0d7210f660d2f9dfe423609913ec6f92a8d75 +development.zh.md: 98b9f7ecd74634ef1e09e3f967913a5f6bf44295 diff --git a/docs/development.md b/docs/development.md index 6c927c46b0..16a0d7210f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. +The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index f29d08df18..98b9f7ecd7 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 +包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 6dd88e5ca8..6fbb078660 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { randomUUID } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { dirname, isAbsolute, join, resolve } from 'node:path' @@ -148,6 +148,57 @@ function assertSingle(values, key) { return values[0] } +function worktreeConfigExtensionEnabled(root, commonConfigPath) { + const extensionText = assertSingle( + fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + 'extensions.worktreeConfig', + ) + return extensionText === undefined + ? false + : parseGitBoolean(extensionText, 'extensions.worktreeConfig') +} + +function hasDirectConfigEntries(root, configPath) { + return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== '' +} + +function registeredWorktreeConfigPaths(commonDirectory) { + const paths = [join(commonDirectory, 'config.worktree')] + const linkedDirectory = join(commonDirectory, 'worktrees') + try { + const entries = readdirSync(linkedDirectory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + for (const entry of entries) { + paths.push(join(linkedDirectory, entry.name, 'config.worktree')) + } + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + return paths +} + +function assertDormantWorktreeConfigs(root, commonDirectory, commonConfigPath, currentConfigPath) { + if (worktreeConfigExtensionEnabled(root, commonConfigPath)) return + for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) { + if (!existsSync(configPath)) continue + const configStat = lstatSync(configPath) + if (!configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error( + `cannot enable extensions.worktreeConfig while dormant worktree config ${JSON.stringify(configPath)} ` + + 'is not a regular file; inspect it and enable the extension explicitly, or remove it, before retrying', + ) + } + if (!hasDirectConfigEntries(root, configPath)) continue + const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath) + const owner = isCurrent ? 'current' : 'sibling' + throw new Error( + `cannot enable extensions.worktreeConfig while ${owner} dormant worktree config ` + + `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; ` + + 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying', + ) + } +} + function assertSupportedGit(root) { const version = git(['--version'], root).stdout.trim() const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version) @@ -229,13 +280,7 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } - const extensionText = assertSingle( - fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), - 'extensions.worktreeConfig', - ) - const extensionEnabled = extensionText === undefined - ? false - : parseGitBoolean(extensionText, 'extensions.worktreeConfig') + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) if (!extensionEnabled) { for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { @@ -576,6 +621,12 @@ async function main() { } assertConditionalHooksPaths(root, worktreeConfigPath) + assertDormantWorktreeConfigs( + root, + commonDirectory, + commonConfigPath, + worktreeConfigPath, + ) ensureOwnedHooksDirectory(hooksPath) ensureWorktreeConfig(root, commonConfigPath) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index b54a7ab864..72272c7a32 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -342,6 +342,24 @@ describe('worktree-local Lefthook installer', () => { expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') }) + it('refuses to activate a sibling worktree dormant hook path', async () => { + const fixture = createFixture() + const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') + const linkedHooks = join(fixture.linked, 'custom-hooks') + git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks]) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('sibling dormant worktree config') + expect(result.stderr).toContain(linkedConfig) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it('refuses migration keys loaded through active or conditional common-config includes', async () => { for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { for (const key of ['core.worktree', 'core.bare']) { From 1f8a3dd7b15797e8482b2836e1e46637486a24c1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:54:04 +0800 Subject: [PATCH 29/41] fix(dev-infra): skip hook setup in CI --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 ++-- .../2026-07-27-worktree-local-lefthook.md | 6 ++++-- .../2026-07-27-worktree-local-lefthook.zh.md | 6 ++++-- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 1 + scripts/install-lefthook.spec.ts | 21 +++++++++++++++++++ 8 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index 3ca9da8947..b66050ca21 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: 2e15f2d242cbcdd56452f3c8ac59bc1e330bc7c0 -2026-07-27-worktree-local-lefthook.zh.md: 742d48511f6fd46202ac9c0ead476105d9b97370 +2026-07-27-worktree-local-lefthook.md: 8bfa6da3de33baf247acb9cb58c8d28abd3c501e +2026-07-27-worktree-local-lefthook.zh.md: 19f5163ddd855a9a9ca9940da01b93b6ee533e6a diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 2e15f2d242..8bfa6da3de 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. With `CI=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. ## Alternatives considered @@ -28,6 +28,8 @@ Enabling worktree config removes the standard redundant `core.bare=false` value **Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit. +**Whitelist provider-specific CI credential-include paths.** Contributor hooks are unused in CI, so path exemptions would couple installer safety to provider checkout internals and weaken strict validation for contributor installs. The CI no-op avoids repository mutation without any exemptions. + **Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index 742d48511f..19f5163ddd 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 ## 考虑过的替代方案 @@ -28,6 +28,8 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 **构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 +**将特定 CI 提供商的凭据 include 路径加入白名单。** CI 不使用贡献者钩子,因此路径豁免会使安装程序的安全性耦合于提供商的检出目录内部结构,并削弱贡献者安装时的严格验证。CI 无操作方案无需任何豁免即可避免修改仓库。 + **停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 ## 后果 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index f21ed31162..80e412e583 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: 16a0d7210f660d2f9dfe423609913ec6f92a8d75 -development.zh.md: 98b9f7ecd74634ef1e09e3f967913a5f6bf44295 +development.md: bd956f5bef4339c2732187ec68302b58a771976f +development.zh.md: 63e226f971102d4989ba57a144a73de9e388612f diff --git a/docs/development.md b/docs/development.md index 16a0d7210f..bd956f5bef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. The wrapper requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: diff --git a/docs/development.zh.md b/docs/development.zh.md index 98b9f7ecd7..63e226f971 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -19,7 +19,7 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 6fbb078660..2c2176da96 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -569,6 +569,7 @@ function refuseScopedHooksPath(entry) { } async function main() { + if (process.env.CI === 'true') return const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return const root = stripGitLineTerminator(probe.stdout) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 72272c7a32..defd7106ce 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -118,6 +118,7 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture const linked = join(container, names.linked ?? 'linked') const env: NodeJS.ProcessEnv = { ...process.env, + CI: 'false', GIT_AUTHOR_EMAIL: 'hooks@example.test', GIT_AUTHOR_NAME: 'Hooks Test', GIT_COMMITTER_EMAIL: 'hooks@example.test', @@ -187,6 +188,26 @@ function runInstaller( } describe('worktree-local Lefthook installer', () => { + it('skips hook installation when CI is true', async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') + git(fixture, fixture.main, [ + 'config', + '--local', + 'includeIf.gitdir:/github/workspace/.git.path', + missingInclude, + ]) + + const result = await runInstaller(fixture, fixture.main, { CI: 'true' }) + + expect(result.status, result.stderr).toBe(0) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(existsSync(join(common, 'config.worktree'))).toBe(false) + }) + it('isolates main and linked worktrees without changing legacy common hooks', async () => { const fixture = createFixture() const common = commonDirectory(fixture) From 74c1a7644046b44a3da05dd3479603a457516cec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:00:36 +0800 Subject: [PATCH 30/41] fix(dev-infra): detect GitHub Actions installs --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 2 +- .../2026-07-27-worktree-local-lefthook.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 2 +- scripts/install-lefthook.spec.ts | 40 +++++++++++-------- 8 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index b66050ca21..c7e7827c5b 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: 8bfa6da3de33baf247acb9cb58c8d28abd3c501e -2026-07-27-worktree-local-lefthook.zh.md: 19f5163ddd855a9a9ca9940da01b93b6ee533e6a +2026-07-27-worktree-local-lefthook.md: f35fe4a91063bca6f29d61932e414d7d4843d2f0 +2026-07-27-worktree-local-lefthook.zh.md: c82e81f5a96f122c961234174fd123259f92ab9e diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 8bfa6da3de..f35fe4a910 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,7 +12,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. With `CI=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index 19f5163ddd..c82e81f5a9 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,7 +12,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。当 `CI=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 80e412e583..d67eb0595d 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: bd956f5bef4339c2732187ec68302b58a771976f -development.zh.md: 63e226f971102d4989ba57a144a73de9e388612f +development.md: dfe99362aa9b881645c69b2bab74180280b4f1b3 +development.zh.md: 10d9129b288d1540b27a9ddc94f1f4acdd3f5f9e diff --git a/docs/development.md b/docs/development.md index bd956f5bef..dfe99362aa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: diff --git a/docs/development.zh.md b/docs/development.zh.md index 63e226f971..10d9129b28 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -19,7 +19,7 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 2c2176da96..275d322179 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -569,7 +569,7 @@ function refuseScopedHooksPath(entry) { } async function main() { - if (process.env.CI === 'true') return + if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }) if (probe.status !== 0) return const root = stripGitLineTerminator(probe.stdout) diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index defd7106ce..1b75385523 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -119,6 +119,7 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture const env: NodeJS.ProcessEnv = { ...process.env, CI: 'false', + GITHUB_ACTIONS: 'false', GIT_AUTHOR_EMAIL: 'hooks@example.test', GIT_AUTHOR_NAME: 'Hooks Test', GIT_COMMITTER_EMAIL: 'hooks@example.test', @@ -188,25 +189,30 @@ function runInstaller( } describe('worktree-local Lefthook installer', () => { - it('skips hook installation when CI is true', async () => { - const fixture = createFixture() - const common = commonDirectory(fixture) - const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') - git(fixture, fixture.main, [ - 'config', - '--local', - 'includeIf.gitdir:/github/workspace/.git.path', - missingInclude, - ]) + for (const [label, extraEnv] of [ + ['CI', { CI: 'true' }], + ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], + ] satisfies [string, NodeJS.ProcessEnv][]) { + it(`skips hook installation when ${label} marks an automated job`, async () => { + const fixture = createFixture() + const common = commonDirectory(fixture) + const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig') + git(fixture, fixture.main, [ + 'config', + '--local', + 'includeIf.gitdir:/github/workspace/.git.path', + missingInclude, + ]) - const result = await runInstaller(fixture, fixture.main, { CI: 'true' }) + const result = await runInstaller(fixture, fixture.main, extraEnv) - expect(result.status, result.stderr).toBe(0) - expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) - expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') - expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) - expect(existsSync(join(common, 'config.worktree'))).toBe(false) - }) + expect(result.status, result.stderr).toBe(0) + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + expect(existsSync(join(common, 'config.worktree'))).toBe(false) + }) + } it('isolates main and linked worktrees without changing legacy common hooks', async () => { const fixture = createFixture() From 81b56a0d97aa3eaaf5367e2c4691618036b4aeea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:07:12 +0800 Subject: [PATCH 31/41] test(dev-infra): refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index cb25d0f061..25a52b2d11 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From f7729b6f53363d2ee6c30b94df93aacfc134f443 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:44 +0800 Subject: [PATCH 32/41] fix(dev-infra): harden worktree hook ownership --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 8 +- .../2026-07-27-worktree-local-lefthook.zh.md | 8 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 189 +++++++++++++++--- scripts/install-lefthook.spec.ts | 168 +++++++++++++++- .../request-response.expected.json | 4 +- 9 files changed, 342 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index c7e7827c5b..abd332a610 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: f35fe4a91063bca6f29d61932e414d7d4843d2f0 -2026-07-27-worktree-local-lefthook.zh.md: c82e81f5a96f122c961234174fd123259f92ab9e +2026-07-27-worktree-local-lefthook.md: 9d5e5583d6d8e3f15433f8b1a6f26f7b2c8cd854 +2026-07-27-worktree-local-lefthook.zh.md: dfca287ae3bbfc1414322d89da9cd93f78230d3c diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index f35fe4a910..9d5e5583d6 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before the format bump, it refuses every existing `extensions.*` key in the common config or a conditional target because format 1 would activate that dormant repository extension. Before first enabling the worktree-config extension, it inspects the `config.worktree` file for the main worktree and every registered linked worktree and refuses dormant settings whose activation would change the current or a sibling worktree. The common repository config and every active or dormant worktree config must be regular files. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. +The installer recognizes its hook directory with a private ownership marker and updates it idempotently. The marker records the absolute path last published to worktree config, so moving a checkout permits the installer to replace that exact stale owned value with the moved `$GIT_DIR/dsh-hooks` path and regenerate hooks; any other worktree-scoped value remains user-owned and is refused. Before invoking Lefthook, the marker and every existing generated hook must be an unaliased regular file, preventing a symlink or additional hard link from redirecting an overwrite outside the owned directory. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. -Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. If that rollback also fails, one diagnostic preserves both failures for manual recovery. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback. +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, checkout relocation, marker and hook alias refusal, stale and replaced lock ownership, the Git version boundary, dormant repository-extension and sibling-config refusal, common and worktree config file ownership, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and successful and failed rollback after installation failure. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index c82e81f5a9..dfca287ae3 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。提升格式版本之前,若共用配置或条件目标中存在任何 `extensions.*` 键,安装程序都会拒绝继续,因为格式 1 会激活这类尚未生效的仓库扩展。首次启用 worktree 配置扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中的 `config.worktree` 文件,并拒绝一经激活就会改变当前或其他 worktree 的尚未生效设置。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 -安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 +安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。该标记会记录上次写入 worktree 配置的绝对路径,因此检出目录移动后,安装程序可以将这一确切的陈旧自有值替换为移动后的 `$GIT_DIR/dsh-hooks` 路径并重新生成钩子;其他 worktree 作用域值仍视为用户自有并会被拒绝。调用 Lefthook 前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件,以防符号链接或额外硬链接把覆盖操作重定向到自有目录外。安装程序会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 -启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 +启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。若回滚也失败,同一条诊断会保留两次失败,供手动恢复。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。 +[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、检出目录移动、拒绝标记和钩子别名、陈旧锁与锁所有权被替换、Git 版本边界、拒绝尚未生效的仓库扩展和其他 worktree 配置、共用及 worktree 配置文件的所有权、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败后成功或失败的回滚。 ## 考虑过的替代方案 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d67eb0595d..8d7d2a2a1e 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: dfe99362aa9b881645c69b2bab74180280b4f1b3 -development.zh.md: 10d9129b288d1540b27a9ddc94f1f4acdd3f5f9e +development.md: f1a853acfd1e89104b8013a5b5e9c4032979234f +development.zh.md: 493284e38ad68768b1159778d5dd50aecfe9ccd1 diff --git a/docs/development.md b/docs/development.md index dfe99362aa..f1a853acfd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. +The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 10d9129b28..493284e38a 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。 +包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 275d322179..04ea62837f 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -7,12 +7,15 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' -const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' +const LEGACY_OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' +const OWNERSHIP_MARKER_VERSION = 1 +const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' const CONDITIONAL_INCLUDE_PATTERN = '^includeif\\..*\\.path$' +const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' function errorCode(error) { return typeof error === 'object' && error !== null && 'code' in error @@ -177,17 +180,37 @@ function registeredWorktreeConfigPaths(commonDirectory) { return paths } -function assertDormantWorktreeConfigs(root, commonDirectory, commonConfigPath, currentConfigPath) { - if (worktreeConfigExtensionEnabled(root, commonConfigPath)) return +function lstatIfPresent(path) { + try { + return lstatSync(path) + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined + throw error + } +} + +function assertCommonConfigFile(commonConfigPath) { + const configStat = lstatIfPresent(commonConfigPath) + if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error( + `refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`, + ) + } +} + +function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) { + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) { - if (!existsSync(configPath)) continue - const configStat = lstatSync(configPath) + const configStat = lstatIfPresent(configPath) + if (configStat === undefined) continue if (!configStat.isFile() || configStat.isSymbolicLink()) { + const state = extensionEnabled ? 'active' : 'dormant' throw new Error( - `cannot enable extensions.worktreeConfig while dormant worktree config ${JSON.stringify(configPath)} ` - + 'is not a regular file; inspect it and enable the extension explicitly, or remove it, before retrying', + `refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; ` + + 'replace it with a regular worktree config or remove it before retrying', ) } + if (extensionEnabled) continue if (!hasDirectConfigEntries(root, configPath)) continue const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath) const owner = isCurrent ? 'current' : 'sibling' @@ -259,7 +282,13 @@ function conditionalIncludeRisk(root, entry, inspect) { return inspectConditionalConfig(root, target, inspect) } -function migrationConfigSubject(root, configPath) { +function migrationConfigSubject(root, configPath, rejectRepositoryExtensions) { + if (rejectRepositoryExtensions) { + const extensionEntry = fileConfigMatchingEntries(root, configPath, REPOSITORY_EXTENSION_PATTERN)[0] + if (extensionEntry !== undefined) { + return `${extensionEntry.name} (${configSource(extensionEntry)})` + } + } const worktreeEntry = fileConfigEntries(root, configPath, 'core.worktree')[0] if (worktreeEntry !== undefined) return `core.worktree (${configSource(worktreeEntry)})` const trueBareEntry = fileConfigEntries(root, configPath, 'core.bare') @@ -272,7 +301,7 @@ function hooksPathConfigSubject(root, configPath) { return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})` } -function ensureWorktreeConfig(root, commonConfigPath) { +function planWorktreeConfigMigration(root, commonConfigPath) { const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') const version = Number(versionText) @@ -280,6 +309,21 @@ function ensureWorktreeConfig(root, commonConfigPath) { throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`) } + if (version === 0) { + const extensionEntry = fileConfigMatchingEntries( + root, + commonConfigPath, + REPOSITORY_EXTENSION_PATTERN, + )[0] + if (extensionEntry !== undefined) { + throw new Error( + `cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension ` + + `${extensionEntry.name} is configured (${configSource(extensionEntry)}); ` + + 'audit and migrate it, then set repository format 1 explicitly before retrying', + ) + } + } + const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) if (!extensionEnabled) { @@ -287,7 +331,7 @@ function ensureWorktreeConfig(root, commonConfigPath) { const risk = conditionalIncludeRisk( root, entry, - configPath => migrationConfigSubject(root, configPath), + configPath => migrationConfigSubject(root, configPath, version === 0), ) if (risk !== undefined) { const reason = risk.subject ?? risk.detail @@ -318,6 +362,11 @@ function ensureWorktreeConfig(root, commonConfigPath) { const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + return { directBare, extensionEnabled, version } +} + +function applyWorktreeConfigMigration(root, commonConfigPath, migration) { + const { directBare, extensionEnabled, version } = migration if (version === 0) { git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root) } @@ -430,13 +479,38 @@ async function acquireInstallLock(commonDirectory) { } } -function ensureOwnedHooksDirectory(hooksPath) { - const markerPath = join(hooksPath, OWNERSHIP_MARKER) - if (!existsSync(hooksPath)) { - mkdirSync(hooksPath, { mode: 0o700 }) - writeFileSync(markerPath, OWNERSHIP_MARKER_CONTENT, { flag: 'wx', mode: 0o600 }) - return +function ownershipMarkerContent(hooksPath) { + return `${JSON.stringify({ + version: OWNERSHIP_MARKER_VERSION, + owner: OWNERSHIP_MARKER_OWNER, + hooksPath, + })}\n` +} + +function parseOwnershipMarker(content, hooksPath) { + if (content === LEGACY_OWNERSHIP_MARKER_CONTENT) return { hooksPath, legacy: true } + let parsed + try { + parsed = JSON.parse(content) + } catch { + return undefined } + if ( + typeof parsed !== 'object' + || parsed === null + || parsed.version !== OWNERSHIP_MARKER_VERSION + || parsed.owner !== OWNERSHIP_MARKER_OWNER + || typeof parsed.hooksPath !== 'string' + || !isAbsolute(parsed.hooksPath) + ) { + return undefined + } + return { hooksPath: parsed.hooksPath, legacy: false } +} + +function inspectOwnedHooksDirectory(hooksPath) { + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + if (!existsSync(hooksPath)) return undefined const hooksStat = lstatSync(hooksPath) if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) { throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`) @@ -445,9 +519,36 @@ function ensureOwnedHooksDirectory(hooksPath) { throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`) } const markerStat = lstatSync(markerPath) - if (!markerStat.isFile() || markerStat.isSymbolicLink() || readFileSync(markerPath, 'utf8') !== OWNERSHIP_MARKER_CONTENT) { + const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1 + ? parseOwnershipMarker(readFileSync(markerPath, 'utf8'), hooksPath) + : undefined + if (marker === undefined) { throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) } + for (const name of readdirSync(hooksPath)) { + if (name === OWNERSHIP_MARKER) continue + const entryPath = join(hooksPath, name) + const entryStat = lstatSync(entryPath) + if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) { + throw new Error( + `refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`, + ) + } + } + return { markerPath, ...marker } +} + +function ensureOwnedHooksDirectory(hooksPath) { + const inspected = inspectOwnedHooksDirectory(hooksPath) + if (inspected !== undefined) return inspected + mkdirSync(hooksPath, { mode: 0o700 }) + const markerPath = join(hooksPath, OWNERSHIP_MARKER) + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 }) + return { markerPath, hooksPath, legacy: false } +} + +function updateOwnershipMarker(markerPath, hooksPath) { + writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 }) } function environmentWithoutCommandGitConfig() { @@ -588,6 +689,13 @@ async function main() { let installationError try { + assertCommonConfigFile(commonConfigPath) + assertWorktreeConfigFiles( + root, + commonDirectory, + commonConfigPath, + worktreeConfigPath, + ) const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') const includedWorktreeEntry = worktreeEntries.find( entry => !originIsFile(entry.origin, root, worktreeConfigPath), @@ -599,14 +707,20 @@ async function main() { worktreeEntries.map(entry => entry.value), 'worktree core.hooksPath', ) + let ownedHooksDirectory if (worktreePath !== undefined && worktreePath !== hooksPath) { - refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) + ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath) + if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) { + refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) + } } + const directWorktreePathIsOwned = worktreePath !== undefined + && (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath) const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') if (effectiveEntry !== undefined) { const effectivePathIsOwned = effectiveEntry.scope === 'worktree' - && effectiveEntry.value === hooksPath - && worktreePath === hooksPath + && effectiveEntry.value === worktreePath + && directWorktreePathIsOwned && originIsFile(effectiveEntry.origin, root, worktreeConfigPath) if (!effectivePathIsOwned) { if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') { @@ -622,19 +736,21 @@ async function main() { } assertConditionalHooksPaths(root, worktreeConfigPath) - assertDormantWorktreeConfigs( - root, - commonDirectory, - commonConfigPath, - worktreeConfigPath, - ) - ensureOwnedHooksDirectory(hooksPath) - ensureWorktreeConfig(root, commonConfigPath) + const migration = planWorktreeConfigMigration(root, commonConfigPath) + ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath) + if ( + worktreePath !== undefined + && worktreePath !== hooksPath + && ownedHooksDirectory.hooksPath !== worktreePath + ) { + throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`) + } + applyWorktreeConfigMigration(root, commonConfigPath, migration) let pathChanged = false try { git(['config', '--worktree', 'core.hooksPath', hooksPath], root) - pathChanged = worktreePath === undefined + pathChanged = worktreePath !== hooksPath const installedEntry = effectiveConfigEntry(root, 'core.hooksPath') if ( installedEntry === undefined @@ -645,9 +761,22 @@ async function main() { throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value') } runLefthook(root, lefthook) + updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath) } catch (error) { if (pathChanged) { - git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + try { + if (worktreePath === undefined) { + git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root) + } else { + git(['config', '--worktree', 'core.hooksPath', worktreePath], root) + } + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Lefthook installation failed: ${String(error)}; ` + + `worktree hook rollback also failed: ${String(rollbackError)}`, + ) + } } throw error } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 1b75385523..0ce0bde993 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -2,10 +2,14 @@ import { spawn, spawnSync } from 'node:child_process' import { chmodSync, existsSync, + linkSync, mkdirSync, mkdtempSync, + lstatSync, readFileSync, + renameSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -91,6 +95,10 @@ if (!shouldFail) { for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 }) } if (existsSync(running)) unlinkSync(running) +if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') { + const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim() + writeFileSync(configPath, '[invalid\\n') +} if (shouldFail) process.exit(77) ` } @@ -275,6 +283,125 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }) + it('repairs its owned absolute hook path after the checkout moves', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const moved = await runInstaller(fixture, movedRoot) + + expect(moved.status, moved.stderr).toBe(0) + const movedHooks = hooksPath(fixture, movedRoot) + expect(movedHooks).not.toBe(oldHooks) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks) + const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel']) + expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`) + expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain( + JSON.stringify(movedHooks), + ) + }) + + it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const externalMarker = join(fixture.container, 'external-marker') + linkSync(join(oldHooks, markerName), externalMarker) + const externalContent = readFileSync(externalMarker, 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const result = await runInstaller(fixture, movedRoot) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('invalid ownership marker') + expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent) + }) + + it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => { + for (const kind of ['symlink', 'hardlink'] as const) { + const fixture = createFixture() + const first = await runInstaller(fixture, fixture.main) + expect(first.status, first.stderr).toBe(0) + const hook = join(hooksPath(fixture, fixture.main), 'pre-commit') + const externalHook = join(fixture.container, `${kind}-external-hook`) + rmSync(hook) + write(externalHook, `external ${kind} target\n`) + if (kind === 'symlink') symlinkSync(externalHook, hook) + else linkSync(externalHook, hook) + const externalContent = readFileSync(externalHook, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('non-regular or multiply linked hook entry') + expect(readFileSync(externalHook, 'utf8')).toBe(externalContent) + } + }) + + it('restores the marker-backed stale hook path when relocation reinstall fails', async () => { + const fixture = createFixture() + const oldRoot = fixture.main + const first = await runInstaller(fixture, oldRoot) + expect(first.status, first.stderr).toBe(0) + const oldHooks = hooksPath(fixture, oldRoot) + const markerName = '.dsh-lefthook-owned' + const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8') + const movedRoot = join(fixture.container, 'moved-main') + renameSync(oldRoot, movedRoot) + + const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' }) + + expect(failed.status).toBe(1) + expect(failed.stderr).toContain('exit status 77') + const movedHooks = hooksPath(fixture, movedRoot) + expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks) + expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker) + }) + + it('refuses dormant repository extensions before upgrading the repository format', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true']) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('dormant repository extension extensions.dshunknown') + expect(git(fixture, fixture.main, [ + 'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion', + ])).toBe('0') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const externalConfig = join(fixture.container, 'external-common.gitconfig') + renameSync(commonConfig, externalConfig) + symlinkSync(externalConfig, commonConfig) + const externalContent = readFileSync(externalConfig, 'utf8') + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('common repository config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it('leaves stale installer locks for explicit recovery', async () => { const fixture = createFixture() const lockPath = installLockPath(fixture) @@ -387,9 +514,33 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) + it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree') + const externalConfig = join(fixture.container, 'external.gitconfig') + const externalContent = '[user]\n\tname = External owner\n' + write(externalConfig, externalContent) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) + git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) + symlinkSync(externalConfig, worktreeConfig) + + const result = await runInstaller(fixture, fixture.main) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('active worktree config') + expect(result.stderr).toContain('not a regular file') + expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true) + expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent) + expect(gitResult(fixture, fixture.main, [ + 'config', '--file', externalConfig, '--get', 'core.hooksPath', + ]).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it('refuses migration keys loaded through active or conditional common-config includes', async () => { for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { - for (const key of ['core.worktree', 'core.bare']) { + for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) @@ -597,6 +748,21 @@ describe('worktree-local Lefthook installer', () => { expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n') }) + it('reports installation and hook-path rollback failures together', async () => { + const fixture = createFixture() + + const result = await runInstaller(fixture, fixture.main, { + DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1', + DSH_TEST_LEFTHOOK_FAIL: '1', + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Lefthook installation failed') + expect(result.stderr).toContain('exit status 77') + expect(result.stderr).toContain('worktree hook rollback also failed') + expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed') + }) + it('refuses an unowned directory at the reserved worktree hook path', async () => { const fixture = createFixture() const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit') diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 25a52b2d11..9903a7ffdc 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From 22e3327f68e2787b26e625cebf60a153a87774c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:21:52 +0800 Subject: [PATCH 33/41] chore: configure Dependabot updates --- ...07-27-dependabot-version-updates.i18n.yaml | 6 ++ .../2026-07-27-dependabot-version-updates.md | 34 +++++++++++ ...026-07-27-dependabot-version-updates.zh.md | 34 +++++++++++ .github/dependabot.yml | 59 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md create mode 100644 .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md create mode 100644 .github/dependabot.yml diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml new file mode 100644 index 0000000000..a569aa7d6c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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/process/2026-07-27-dependabot-version-updates.md +2026-07-27-dependabot-version-updates.md: 7a2eadd369f36c1744ddcd52a7065438ab6513d2 +2026-07-27-dependabot-version-updates.zh.md: a0f0db9152f76769c4e3e7aa19e0dfde59ad14db diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md new file mode 100644 index 0000000000..7a2eadd369 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -0,0 +1,34 @@ +# Agent Note: Dependabot version updates with a 30-day cooldown + +Status: implemented + +English | [中文](2026-07-27-dependabot-version-updates.zh.md) + +## Problem + +Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources and independently locked workspaces also cannot be treated as one undifferentiated package tree. + +## Decision + +The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, the independently locked `native/landlock-run` pnpm workspace, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. + +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. + +Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. + +The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. + +## Alternatives considered + +- **Immediate version updates.** Rejected because they remove the requested release-age quarantine and make the project an early consumer of every upstream release. +- **Automatic merging after CI.** Rejected because dependency changes can alter runtime, build, and release behavior; the normal review decision remains part of accepting an update. +- **One recursive npm scan.** Rejected because it could admit vendored manifests or conflate the root and native lockfiles. Explicit exclusions and a dedicated native entry preserve their ownership boundaries. +- **Renovate or a scheduled agent.** Both can propose aged updates, but Dependabot is the requested service and the repository's CI already recognizes its pull requests as an untrusted dependency source. +- **Cooldown exemptions for coordinated fresh releases.** Rejected for the automated path because those releases require an explicit synchronization or model-catalog decision rather than a generic update proposal. + +## Consequences + +- Routine dependency updates arrive in small reviewable pull requests after the quarantine instead of requiring periodic manual discovery. +- A release normally appears between 30 and 36 days after publication because eligibility is evaluated weekly. +- Security updates are not delayed; review preserves the vendoring boundary when an update reaches a vendored manifest. +- Maintainers still decide whether to merge each update and diagnose any provider limitation reported by the pnpm 11 update job. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md new file mode 100644 index 0000000000..a0f0db9152 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -0,0 +1,34 @@ +# Agent Note: Dependabot 版本更新采用 30 天冷却期 + +Status: implemented + +[English](2026-07-27-dependabot-version-updates.md) | 中文 + +## 问题 + +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包(package)树。 + +## 决策 + +默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为根 pnpm 工作区、独立维护锁文件的 `native/landlock-run` pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。 + +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 + +仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 + +pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 + +## 考虑过的替代方案 + +- **立即进行版本更新。** 不采用,因为这会取消所要求的版本发布后隔离期,使项目在每个上游版本的发布初期就采用该版本。 +- **CI 通过后自动合并。** 不采用,因为依赖变更可能改变运行时、构建和发布行为;是否接受更新仍须经过常规评审决策。 +- **使用一次递归 npm 扫描。** 不采用,因为它可能将随源码纳入仓库的 manifest 纳入更新范围,或混淆根工作区与 native 工作区的锁文件。显式排除项和专用 native 更新项可维持各自的归属边界。 +- **Renovate 或定期运行的 agent(智能体)。** 二者都能为发布已满一定时长的版本提出更新,但所要求的服务是 Dependabot,而且仓库 CI 已将其 PR 视为不可信的依赖来源。 +- **为需协调的刚发布版本设置冷却期豁免。** 自动化路径不采用,因为此类版本需要明确的同步决策或模型目录决策,不能由通用更新提案代替。 + +## 后果 + +- 隔离期结束后,常规依赖更新会以规模较小、便于评审的 PR 形式到达,无需维护者定期手动发现更新。 +- 由于每周评估一次更新资格,相应更新 PR 通常会在版本发布后 30 至 36 天出现。 +- 安全更新不会延迟;如果更新触及随源码纳入仓库的 manifest,评审流程仍会维持 vendoring 边界。 +- 维护者仍负责决定是否合并每项更新,并诊断 pnpm 11 更新任务报告的任何提供方限制。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..2df167670e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,59 @@ +version: 2 + +updates: + - package-ecosystem: "npm" + directory: "/" + exclude-paths: + # Vendored Cordis sources follow vendor/README.md instead of registry updates. + - "vendor/**" + # This independently locked pnpm workspace has its own update entry below. + - "native/landlock-run/**" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "npm" + directory: "/native/landlock-run" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "uv" + directory: "/python/sdk" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" From 7759b1d682149d6604297db674b18379e104476a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:27:07 +0800 Subject: [PATCH 34/41] docs: record Dependabot security update boundary --- .../process/2026-07-27-dependabot-version-updates.i18n.yaml | 4 ++-- .../process/2026-07-27-dependabot-version-updates.md | 4 ++-- .../process/2026-07-27-dependabot-version-updates.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index a569aa7d6c..961eeb8f0d 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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/process/2026-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 7a2eadd369f36c1744ddcd52a7065438ab6513d2 -2026-07-27-dependabot-version-updates.zh.md: a0f0db9152f76769c4e3e7aa19e0dfde59ad14db +2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 +2026-07-27-dependabot-version-updates.zh.md: 1ab34e76b84c7423be76fa79ae6bb3705a07a76c diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 7a2eadd369..725649652c 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -14,7 +14,7 @@ The default branch carries [`.github/dependabot.yml`](../../../../.github/depend The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. -Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. +Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. @@ -30,5 +30,5 @@ The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introdu - Routine dependency updates arrive in small reviewable pull requests after the quarantine instead of requiring periodic manual discovery. - A release normally appears between 30 and 36 days after publication because eligibility is evaluated weekly. -- Security updates are not delayed; review preserves the vendoring boundary when an update reaches a vendored manifest. +- Dependabot does not delay security proposals; repository checks can still block unrelated fresh transitives, and review preserves the vendoring boundary. - Maintainers still decide whether to merge each update and diagnose any provider limitation reported by the pnpm 11 update job. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index a0f0db9152..1ab34e76b8 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -14,7 +14,7 @@ Status: implemented 根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 -仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 +仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 @@ -30,5 +30,5 @@ pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为 - 隔离期结束后,常规依赖更新会以规模较小、便于评审的 PR 形式到达,无需维护者定期手动发现更新。 - 由于每周评估一次更新资格,相应更新 PR 通常会在版本发布后 30 至 36 天出现。 -- 安全更新不会延迟;如果更新触及随源码纳入仓库的 manifest,评审流程仍会维持 vendoring 边界。 +- Dependabot 不会延迟安全更新提案;仓库检查仍可阻止无关的刚发布传递依赖,评审流程也会维持 vendoring 边界。 - 维护者仍负责决定是否合并每项更新,并诊断 pnpm 11 更新任务报告的任何提供方限制。 From 4866a76d8692af88ef7523781fc507b64dcf3abf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:29:01 +0800 Subject: [PATCH 35/41] test(llm): match pi-ai 0.82.1 reasoning catalog --- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cb5e60c343..f76fb22ef1 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -373,7 +373,6 @@ describe('provider profile lifecycle', () => { const extended = await ctx.llm.resolveModelInfo('openai', 'gpt-5.6-sol') expect(extended.reasoning?.efforts.map(effort => effort.id)).toEqual([ ReasoningEffortId('off'), - ReasoningEffortId('minimal'), ReasoningEffortId('low'), ReasoningEffortId('medium'), ReasoningEffortId('high'), From 355975fbd5a95839826e934719d679da7af61205 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:31:29 +0800 Subject: [PATCH 36/41] chore: schedule Dependabot for Friday Shanghai time --- .github/dependabot.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2df167670e..c69503be23 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,8 @@ updates: - "native/landlock-run/**" schedule: interval: "weekly" - day: "monday" - time: "09:00" + day: "friday" + time: "00:01" timezone: "Asia/Shanghai" cooldown: default-days: 30 @@ -23,8 +23,8 @@ updates: directory: "/native/landlock-run" schedule: interval: "weekly" - day: "monday" - time: "09:00" + day: "friday" + time: "00:01" timezone: "Asia/Shanghai" cooldown: default-days: 30 @@ -36,8 +36,8 @@ updates: directory: "/python/sdk" schedule: interval: "weekly" - day: "monday" - time: "09:00" + day: "friday" + time: "00:01" timezone: "Asia/Shanghai" cooldown: default-days: 30 @@ -49,8 +49,8 @@ updates: directory: "/" schedule: interval: "weekly" - day: "monday" - time: "09:00" + day: "friday" + time: "00:01" timezone: "Asia/Shanghai" cooldown: default-days: 30 From 892577ee526545a40ebeb612f753fac2758fad8d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:38:06 +0800 Subject: [PATCH 37/41] test(llm-retry): cover incomplete retry predecessors --- .../llm/llm-retry/tests/invariant.spec.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index a0d0bdba35..978a9fc9a8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -27,6 +27,17 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { return session } +function appendRetryTurn(session: Session, turn: number) { + session.append('turn/start', { turn, trigger: { kind: 'retry' } }) + session.append('step/start', { turn, step: 1 }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) + session.append('step/end', { turn, step: 1 }) + session.append('llm/retry', { turn, step: 1, ...normal }) +} + const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } const normal = { provider: 'mock', @@ -225,6 +236,36 @@ describe('llm-retry invariants', () => { }).not.toThrow() }) + it('starts a fresh retry chain after incomplete predecessor boundaries', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + + const missingEnd = ctx.sessions.create(SessionId('retry-invariant-missing-end')) + missingEnd.append('user/message', { + content: [{ type: 'text', text: 'idle context' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendRetryTurn(missingEnd, 2) + + const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end')) + nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + nonFailureEnd.append('user/message', { + content: [{ type: 'text', text: 'idle context' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendRetryTurn(nonFailureEnd, 2) + + const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start')) + missingStart.append('turn/end', { + turn: 1, + reason: { kind: 'error', step: 1, failure }, + }) + appendRetryTurn(missingStart, 2) + + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() + }) + it('rejects a provider that does not match the failed request route', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-provider') From 6241eb6044579b4b15e9040582f77c7a1e0eba84 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:38:49 +0800 Subject: [PATCH 38/41] Require Agent Note supersession checks --- .agents/notes/AGENTS.md | 2 ++ .../process/2026-07-26-frozen-agent-note-archive.i18n.yaml | 6 +++--- .../process/2026-07-26-frozen-agent-note-archive.md | 6 +++++- .../process/2026-07-26-frozen-agent-note-archive.zh.md | 6 +++++- .agents/skills/dsh-archive-agent-notes/SKILL.md | 6 +++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.agents/notes/AGENTS.md b/.agents/notes/AGENTS.md index ea0fa8f42c..e8e1a0ef66 100644 --- a/.agents/notes/AGENTS.md +++ b/.agents/notes/AGENTS.md @@ -2,4 +2,6 @@ Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md). +**Every new Agent Note triggers a supersession check.** Search the active tree for older notes covering the same decision or mechanism, classify any full or partial supersession with [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md), and archive every qualifying implemented triplet in the same PR. Keep partial supersessions active and cross-linked. + Files under [`archived/`](archived/AGENTS.md) are frozen historical snapshots: never edit them or treat them as current authority. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 998417a651..b7e099dec5 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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-26-frozen-agent-note-archive.md: e829d30853c7b80dee76da0fdc22db7e9b04e828 -2026-07-26-frozen-agent-note-archive.zh.md: f90a81561eb4c11c8d48400d2adfbcfff7e6a9ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 +2026-07-26-frozen-agent-note-archive.zh.md: 9362fbcb268045f0cb6762bf6804a54eed34caee diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index e829d30853..52b43088b2 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -20,12 +20,16 @@ After archival, the triplet is permanently frozen and is historical context rath The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review. +Supersession is checked while a new Agent Note is being written, not deferred to a later corpus cleanup. The author compares the new note with active notes covering the same decision, mechanism, or rejected alternative and classifies every full or partial supersession. Qualifying implemented triplets are archived in the same pull request; partial supersessions and independently useful rationale remain active and cross-linked, while proposed and rejected matches follow their own lifecycle rules. + ## Alternatives considered **Delete every note that leaves the active corpus.** Rejected because an implemented record can have low forward guidance while still providing useful historical evidence about a closed decision. A content-sealed archive preserves that evidence without pretending it remains current. **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. + **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. **Continue applying all documentation gates to archived notes.** Rejected because a later formatting, translation, code, package, or link rule would require rewriting the historical snapshot. The dedicated verifier owns completeness and immutability instead. @@ -34,4 +38,4 @@ The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index f90a81561e..9362fbcb26 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -20,12 +20,16 @@ implemented Agent Note(agent 决策记录)作为当前决策记录持续维 [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。 +在编写新的 Agent Note 时就检查取代关系,而不是推迟到日后清理记录集合时再处理。作者会将新记录与涵盖同一项决策、机制或被否决备选方案的活跃记录进行比较,并逐项判定属于完全取代还是部分取代。符合条件的 implemented Agent Note 三文件配对会在同一个拉取请求中归档;仅部分被取代的记录,以及仍保有独立价值的决策依据,会继续作为活跃记录保留并与新记录互相链接,而匹配到的 proposed 和 rejected Agent Note 则遵循各自的生命周期规则。 + ## 曾考虑的替代方案 **删除每一份移出活跃记录集合的记录。** 不予采纳,因为已实施记录可能对未来的指导价值较低,却仍能为已经收尾的决策提供有用的历史证据。按内容 hash 封存的归档既能保留这些证据,又不会假装它们仍然反映当前状态。 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 + **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 **继续对归档 Agent Note 应用所有文档门禁。** 不予采纳,因为后续新增的格式、翻译、代码、包或链接规则会迫使维护者重写历史快照。改由专用校验器负责完整性与不可变性。 @@ -34,4 +38,4 @@ implemented Agent Note(agent 决策记录)作为当前决策记录持续维 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index 319cddc46d..e4df7e1fe7 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. --- # Archive DeepSeek Harness Agent Notes @@ -11,6 +11,10 @@ Reduce the active decision corpus without erasing history that can still guide w Read [the Agent Note contract](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. +## Check supersession when adding a note + +Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note contract's consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. + ## Classify by future value Apply these lifecycle-specific outcomes: 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 39/41] 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 40/41] 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) }) From c982cf780586075d4d1bdfa06602c2a840248dec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:31 +0800 Subject: [PATCH 41/41] refactor(dev-infra): narrow worktree hook safety checks --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 10 +- .../2026-07-27-worktree-local-lefthook.zh.md | 10 +- docs/development.i18n.yaml | 4 +- docs/development.md | 6 +- docs/development.zh.md | 6 +- scripts/install-lefthook.mjs | 221 +++--------------- scripts/install-lefthook.spec.ts | 145 +++--------- .../request-response.expected.json | 4 +- 9 files changed, 93 insertions(+), 317 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index abd332a610..34dcf42c4f 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: 9d5e5583d6d8e3f15433f8b1a6f26f7b2c8cd854 -2026-07-27-worktree-local-lefthook.zh.md: dfca287ae3bbfc1414322d89da9cd93f78230d3c +2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c +2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 9d5e5583d6..d18f6c1bf8 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal ## Decision -Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before the format bump, it refuses every existing `extensions.*` key in the common config or a conditional target because format 1 would activate that dormant repository extension. Before first enabling the worktree-config extension, it inspects the `config.worktree` file for the main worktree and every registered linked worktree and refuses dormant settings whose activation would change the current or a sibling worktree. The common repository config and every active or dormant worktree config must be regular files. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually. +Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. -The installer recognizes its hook directory with a private ownership marker and updates it idempotently. The marker records the absolute path last published to worktree config, so moving a checkout permits the installer to replace that exact stale owned value with the moved `$GIT_DIR/dsh-hooks` path and regenerate hooks; any other worktree-scoped value remains user-owned and is refused. Before invoking Lefthook, the marker and every existing generated hook must be an unaliased regular file, preventing a symlink or additional hard link from redirecting an overwrite outside the owned directory. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers. +Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. -Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. If that rollback also fails, one diagnostic preserves both failures for manual recovery. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer. +Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, checkout relocation, marker and hook alias refusal, stale and replaced lock ownership, the Git version boundary, dormant repository-extension and sibling-config refusal, common and worktree config file ownership, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and successful and failed rollback after installation failure. +If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback. ## Alternatives considered @@ -36,6 +36,6 @@ Enabling worktree config removes the standard redundant `core.bare=false` value Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged. -The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.26. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. +The repository becomes a Git format-1 repository after the first installation. The installer requires Git 2.26 for `--show-scope`; the worktree-config extension itself predates that command. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index dfca287ae3..42a1625a3b 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 ## 决策 -钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。提升格式版本之前,若共用配置或条件目标中存在任何 `extensions.*` 键,安装程序都会拒绝继续,因为格式 1 会激活这类尚未生效的仓库扩展。首次启用 worktree 配置扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中的 `config.worktree` 文件,并拒绝一经激活就会改变当前或其他 worktree 的尚未生效设置。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。 +钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。 -安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。该标记会记录上次写入 worktree 配置的绝对路径,因此检出目录移动后,安装程序可以将这一确切的陈旧自有值替换为移动后的 `$GIT_DIR/dsh-hooks` 路径并重新生成钩子;其他 worktree 作用域值仍视为用户自有并会被拒绝。调用 Lefthook 前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件,以防符号链接或额外硬链接把覆盖操作重定向到自有目录外。安装程序会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。 +升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。 -启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` 或 `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。若回滚也失败,同一条诊断会保留两次失败,供手动恢复。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。 +每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 -[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、检出目录移动、拒绝标记和钩子别名、陈旧锁与锁所有权被替换、Git 版本边界、拒绝尚未生效的仓库扩展和其他 worktree 配置、共用及 worktree 配置文件的所有权、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败后成功或失败的回滚。 +若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。 ## 考虑过的替代方案 @@ -36,6 +36,6 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。 -首次安装后,仓库会采用 Git 格式版本 1,并拒绝版本低于 Git 2.26 的客户端。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 +首次安装后,仓库会采用 Git 格式版本 1。安装程序需要 Git 2.26 来使用 `--show-scope`;worktree 配置扩展本身的出现早于该命令。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8d7d2a2a1e..74d9dfbea5 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: f1a853acfd1e89104b8013a5b5e9c4032979234f -development.zh.md: 493284e38ad68768b1159778d5dd50aecfe9ccd1 +development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca +development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc diff --git a/docs/development.md b/docs/development.md index f1a853acfd..32339fa2af 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,11 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying. +The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly. + +Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. + +After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract. Run typecheck once after a fresh clone: diff --git a/docs/development.zh.md b/docs/development.zh.md index 493284e38a..c74a813466 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,11 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。 +包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。 + +启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 + +检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。 新克隆后请先运行一次类型检查: diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 04ea62837f..49c246df69 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -2,19 +2,17 @@ import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' const OWNERSHIP_MARKER = '.dsh-lefthook-owned' -const LEGACY_OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n' const OWNERSHIP_MARKER_VERSION = 1 const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' -const CONDITIONAL_INCLUDE_PATTERN = '^includeif\\..*\\.path$' const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' function errorCode(error) { @@ -59,20 +57,15 @@ function stripGitLineTerminator(output) { : withoutLineFeed } -function fileConfigValues(root, configPath, key) { +function directFileConfigValues(root, configPath, key) { return nulValues(git( - ['config', '--file', configPath, '--null', '--get-all', key], + ['config', '--file', configPath, '--no-includes', '--null', '--get-all', key], root, { allowStatuses: [1] }, )) } -function fileConfigEntries(root, configPath, key) { - const fields = nulValues(git( - ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], - root, - { allowStatuses: [1] }, - )) +function parseFileConfigEntries(fields, key) { if (fields.length % 2 !== 0) { throw new Error(`git config returned invalid file entries for ${key}`) } @@ -83,15 +76,24 @@ function fileConfigEntries(root, configPath, key) { return entries } +function includedFileConfigEntries(root, configPath, key) { + const fields = nulValues(git( + ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key], + root, + { allowStatuses: [1] }, + )) + return parseFileConfigEntries(fields, key) +} + function splitConfigNameValue(field, pattern) { const separator = field.indexOf('\n') if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`) return { name: field.slice(0, separator), value: field.slice(separator + 1) } } -function fileConfigMatchingEntries(root, configPath, pattern) { +function directFileConfigMatchingEntries(root, configPath, pattern) { const fields = nulValues(git( - ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern], + ['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern], root, { allowStatuses: [1] }, )) @@ -105,26 +107,6 @@ function fileConfigMatchingEntries(root, configPath, pattern) { return entries } -function scopedConfigMatchingEntries(root, pattern) { - const fields = nulValues(git( - ['config', '--includes', '--null', '--show-scope', '--show-origin', '--get-regexp', pattern], - root, - { allowStatuses: [1] }, - )) - if (fields.length % 3 !== 0) { - throw new Error(`git config returned invalid scoped entries for ${pattern}`) - } - const entries = [] - for (let index = 0; index < fields.length; index += 3) { - entries.push({ - scope: fields[index], - origin: fields[index + 1], - ...splitConfigNameValue(fields[index + 2], pattern), - }) - } - return entries -} - function effectiveConfigEntry(root, key) { const fields = nulValues(git( ['config', '--null', '--show-scope', '--show-origin', '--get', key], @@ -153,7 +135,7 @@ function assertSingle(values, key) { function worktreeConfigExtensionEnabled(root, commonConfigPath) { const extensionText = assertSingle( - fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), + directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'), 'extensions.worktreeConfig', ) return extensionText === undefined @@ -162,7 +144,7 @@ function worktreeConfigExtensionEnabled(root, commonConfigPath) { } function hasDirectConfigEntries(root, configPath) { - return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== '' + return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== '' } function registeredWorktreeConfigPaths(commonDirectory) { @@ -235,74 +217,8 @@ function assertSupportedGit(root) { } } -function conditionalIncludeTarget(entry, root) { - if (isAbsolute(entry.value)) return entry.value - const sourcePath = configOriginPath(entry.origin, root) - if (sourcePath === undefined) return undefined - if (entry.value.startsWith('~/')) { - const home = process.env.HOME - return home === undefined ? undefined : resolve(home, entry.value.slice(2)) - } - if (entry.value.startsWith('~') || entry.value.startsWith('%(')) return undefined - return resolve(dirname(sourcePath), entry.value) -} - -function inspectConditionalConfig(root, configPath, inspect, seen = new Set()) { - const identity = normalizedPath(configPath) - if (seen.has(identity)) return undefined - seen.add(identity) - if (!existsSync(configPath)) { - return { configPath, detail: 'the included config does not exist and cannot be inspected' } - } - try { - const subject = inspect(configPath) - if (subject !== undefined) return { configPath, subject } - for (const entry of fileConfigMatchingEntries(root, configPath, CONDITIONAL_INCLUDE_PATTERN)) { - const target = conditionalIncludeTarget(entry, root) - if (target === undefined) { - return { configPath, detail: `the nested include path ${JSON.stringify(entry.value)} cannot be resolved safely` } - } - const nested = inspectConditionalConfig(root, target, inspect, seen) - if (nested !== undefined) return nested - } - return undefined - } catch (error) { - return { - configPath, - detail: `the included config could not be inspected: ${error instanceof Error ? error.message : String(error)}`, - } - } -} - -function conditionalIncludeRisk(root, entry, inspect) { - const target = conditionalIncludeTarget(entry, root) - if (target === undefined) { - return { detail: `the include path ${JSON.stringify(entry.value)} cannot be resolved safely` } - } - return inspectConditionalConfig(root, target, inspect) -} - -function migrationConfigSubject(root, configPath, rejectRepositoryExtensions) { - if (rejectRepositoryExtensions) { - const extensionEntry = fileConfigMatchingEntries(root, configPath, REPOSITORY_EXTENSION_PATTERN)[0] - if (extensionEntry !== undefined) { - return `${extensionEntry.name} (${configSource(extensionEntry)})` - } - } - const worktreeEntry = fileConfigEntries(root, configPath, 'core.worktree')[0] - if (worktreeEntry !== undefined) return `core.worktree (${configSource(worktreeEntry)})` - const trueBareEntry = fileConfigEntries(root, configPath, 'core.bare') - .find(entry => parseGitBoolean(entry.value, 'core.bare')) - return trueBareEntry === undefined ? undefined : `core.bare=true (${configSource(trueBareEntry)})` -} - -function hooksPathConfigSubject(root, configPath) { - const entry = fileConfigEntries(root, configPath, 'core.hooksPath')[0] - return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})` -} - function planWorktreeConfigMigration(root, commonConfigPath) { - const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') + const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion') const versionText = assertSingle(versions, 'core.repositoryFormatVersion') const version = Number(versionText) if (!Number.isInteger(version) || version < 0) { @@ -310,7 +226,7 @@ function planWorktreeConfigMigration(root, commonConfigPath) { } if (version === 0) { - const extensionEntry = fileConfigMatchingEntries( + const extensionEntry = directFileConfigMatchingEntries( root, commonConfigPath, REPOSITORY_EXTENSION_PATTERN, @@ -325,42 +241,26 @@ function planWorktreeConfigMigration(root, commonConfigPath) { } const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath) - - if (!extensionEnabled) { - for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) { - const risk = conditionalIncludeRisk( - root, - entry, - configPath => migrationConfigSubject(root, configPath, version === 0), - ) - if (risk !== undefined) { - const reason = risk.subject ?? risk.detail - throw new Error( - `cannot enable extensions.worktreeConfig while common conditional include ` - + `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)} may provide migration-sensitive config (${reason}); ` - + 'audit and migrate it, then enable the extension explicitly', - ) - } - } - } - - const worktreeEntry = fileConfigEntries(root, commonConfigPath, 'core.worktree')[0] - if (worktreeEntry !== undefined) { + const worktreeText = assertSingle( + directFileConfigValues(root, commonConfigPath, 'core.worktree'), + 'core.worktree', + ) + if (worktreeText !== undefined) { throw new Error( - `cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); ` + `cannot enable extensions.worktreeConfig while core.worktree is in the common config ` + + `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); ` + 'move it to the main worktree config first', ) } - const bareEntries = fileConfigEntries(root, commonConfigPath, 'core.bare') - const trueBareEntry = bareEntries.find(entry => parseGitBoolean(entry.value, 'core.bare')) - if (trueBareEntry !== undefined) { + const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') + const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') + if (directBare === true) { throw new Error( - `cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`, + `cannot enable extensions.worktreeConfig for a common config with core.bare=true ` + + `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`, ) } - const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare') - const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare') return { directBare, extensionEnabled, version } } @@ -487,8 +387,7 @@ function ownershipMarkerContent(hooksPath) { })}\n` } -function parseOwnershipMarker(content, hooksPath) { - if (content === LEGACY_OWNERSHIP_MARKER_CONTENT) return { hooksPath, legacy: true } +function parseOwnershipMarker(content) { let parsed try { parsed = JSON.parse(content) @@ -505,7 +404,7 @@ function parseOwnershipMarker(content, hooksPath) { ) { return undefined } - return { hooksPath: parsed.hooksPath, legacy: false } + return { hooksPath: parsed.hooksPath } } function inspectOwnedHooksDirectory(hooksPath) { @@ -520,7 +419,7 @@ function inspectOwnedHooksDirectory(hooksPath) { } const markerStat = lstatSync(markerPath) const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1 - ? parseOwnershipMarker(readFileSync(markerPath, 'utf8'), hooksPath) + ? parseOwnershipMarker(readFileSync(markerPath, 'utf8')) : undefined if (marker === undefined) { throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`) @@ -544,7 +443,7 @@ function ensureOwnedHooksDirectory(hooksPath) { mkdirSync(hooksPath, { mode: 0o700 }) const markerPath = join(hooksPath, OWNERSHIP_MARKER) writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 }) - return { markerPath, hooksPath, legacy: false } + return { markerPath, hooksPath } } function updateOwnershipMarker(markerPath, hooksPath) { @@ -597,52 +496,6 @@ function originIsFile(origin, root, configPath) { return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath) } -function conditionalIncludeSource(entry) { - return `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)}` -} - -function conditionalIncludes(root, worktreeConfigPath) { - const entries = scopedConfigMatchingEntries(root, CONDITIONAL_INCLUDE_PATTERN) - entries.push(...fileConfigMatchingEntries(root, worktreeConfigPath, CONDITIONAL_INCLUDE_PATTERN) - .map(entry => ({ ...entry, scope: 'worktree' }))) - const unique = new Map() - for (const entry of entries) { - unique.set(`${entry.scope}\0${entry.origin}\0${entry.name}\0${entry.value}`, entry) - } - return [...unique.values()] -} - -function assertConditionalHooksPaths(root, worktreeConfigPath) { - for (const entry of conditionalIncludes(root, worktreeConfigPath)) { - const risk = conditionalIncludeRisk( - root, - entry, - configPath => hooksPathConfigSubject(root, configPath), - ) - if (risk === undefined) continue - const reason = risk.subject ?? risk.detail - if (entry.scope === 'command' || entry.scope === 'worktree') { - throw new Error( - `refusing ${entry.scope}-scoped conditional include ${conditionalIncludeSource(entry)}; ` - + `it may provide a user-owned core.hooksPath (${reason}) and cannot be overridden`, - ) - } - if (!['system', 'global', 'local'].includes(entry.scope)) { - throw new Error( - `refusing conditional include from unsupported ${entry.scope} scope ${conditionalIncludeSource(entry)}; ` - + `it may provide core.hooksPath (${reason})`, - ) - } - if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') { - throw new Error( - `refusing to replace core.hooksPath that may be provided by inherited conditional include ` - + `${conditionalIncludeSource(entry)} (${reason}). Inspect that include and rerun with ` - + `${ALLOW_HOOKS_PATH_OVERRIDE}=1 only if it may remain active in other worktrees`, - ) - } - } -} - function refuseInheritedHooksPath(entry) { throw new Error( `refusing to replace user-owned core.hooksPath (${configSource(entry)}). ` @@ -696,7 +549,7 @@ async function main() { commonConfigPath, worktreeConfigPath, ) - const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') + const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath') const includedWorktreeEntry = worktreeEntries.find( entry => !originIsFile(entry.origin, root, worktreeConfigPath), ) @@ -734,8 +587,6 @@ async function main() { } } } - assertConditionalHooksPaths(root, worktreeConfigPath) - const migration = planWorktreeConfigMigration(root, commonConfigPath) ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath) if ( diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 0ce0bde993..7e30c887ec 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -384,6 +384,19 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) + it('refuses direct core.worktree before enabling worktree config', async () => { + const fixture = createFixture() + const commonConfig = join(commonDirectory(fixture), 'config') + git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('core.worktree is in the common config') + expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + }) + it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') @@ -538,9 +551,9 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('refuses migration keys loaded through active or conditional common-config includes', async () => { - for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { - for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { + for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) { + for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) { + it(`ignores ${key} loaded through ${includeKey}`, async () => { const fixture = createFixture() const commonConfig = join(commonDirectory(fixture), 'config') const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`) @@ -550,25 +563,27 @@ describe('worktree-local Lefthook installer', () => { const result = await runInstaller(fixture, fixture.linked) - expect(result.status).toBe(1) - expect(result.stderr).toContain(key) - expect(result.stderr).toContain(includedConfig) - expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1) - expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(false) - } + expect(result.status, result.stderr).toBe(0) + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe( + hooksPath(fixture, fixture.linked), + ) + expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true) + }) } - }) + } - it('allows a conditional common-config include unrelated to migration or hooks', async () => { + it('ignores an inactive global includeIf that provides a hook path for another repository', async () => { const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const includedConfig = join(fixture.container, 'conditional-identity.gitconfig') - git(fixture, fixture.main, ['config', '--file', includedConfig, 'user.email', 'conditional@example.test']) + const globalConfig = fixture.env.GIT_CONFIG_GLOBAL + if (globalConfig === undefined) throw new Error('fixture global config path is missing') + const includedConfig = join(fixture.container, 'other-repository.gitconfig') + const includedHooks = join(fixture.container, 'other-repository-hooks') + git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) git(fixture, fixture.main, [ 'config', '--file', - commonConfig, - 'includeIf.onbranch:conditional.path', + globalConfig, + `includeIf.gitdir:${join(fixture.container, 'other')}/.path`, includedConfig, ]) @@ -598,24 +613,6 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('never overrides a hook path behind a command-scoped conditional include', async () => { - const fixture = createFixture() - const includedConfig = join(fixture.container, 'command-conditional.gitconfig') - const includedHooks = join(fixture.container, 'command-conditional-hooks') - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - - const result = await runInstaller(fixture, fixture.main, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'includeIf.onbranch:conditional.path', - GIT_CONFIG_VALUE_0: includedConfig, - }) - - expect(result.status).toBe(1) - expect(result.stderr).toContain('command-scoped conditional include') - expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) - }) - it('does not pass unrelated command-scoped Git config to Lefthook', async () => { const fixture = createFixture() @@ -654,86 +651,6 @@ describe('worktree-local Lefthook installer', () => { expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) }) - it('refuses an inactive conditional worktree include that can later provide a hook path', async () => { - const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const worktreeConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') - const includedConfig = join(fixture.container, 'conditional-worktree.gitconfig') - const includedHooks = join(fixture.container, 'conditional-hooks') - const sentinel = join(includedHooks, 'pre-commit') - write(sentinel, '#!/bin/sh\n# conditional-worktree sentinel\n', 0o755) - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) - git(fixture, fixture.main, [ - 'config', - '--file', - worktreeConfig, - 'includeIf.onbranch:conditional.path', - includedConfig, - ]) - - const result = await runInstaller(fixture, fixture.linked) - - expect(result.status).toBe(1) - expect(result.stderr).toContain('worktree-scoped conditional include') - expect(result.stderr).toContain('includeif.onbranch:conditional.path') - expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) - expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false) - - git(fixture, fixture.linked, ['switch', '-c', 'conditional']) - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks) - expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# conditional-worktree sentinel\n') - }) - - it('requires opt-in for inherited conditional includes that can later provide a hook path', async () => { - for (const scope of ['local', 'global']) { - const fixture = createFixture() - const commonConfig = join(commonDirectory(fixture), 'config') - const conditionalOwner = scope === 'local' - ? commonConfig - : fixture.env.GIT_CONFIG_GLOBAL - if (conditionalOwner === undefined) throw new Error('fixture global config path is missing') - const includedConfig = join(fixture.container, `${scope}-conditional.gitconfig`) - const includedHooks = join(fixture.container, `${scope}-conditional-hooks`) - git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks]) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1']) - git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true']) - git(fixture, fixture.main, [ - 'config', - '--file', - conditionalOwner, - 'includeIf.onbranch:conditional.path', - includedConfig, - ]) - - const refused = await runInstaller(fixture, fixture.linked) - - expect(refused.status).toBe(1) - expect(refused.stderr).toContain('inherited conditional include') - expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1') - expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1) - - const optedIn = await runInstaller(fixture, fixture.linked, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - }) - expect(optedIn.status, optedIn.stderr).toBe(0) - - git(fixture, fixture.linked, ['switch', '-c', 'conditional']) - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) - - const repeatedRefusal = await runInstaller(fixture, fixture.linked) - expect(repeatedRefusal.status).toBe(1) - expect(repeatedRefusal.stderr).toContain('inherited conditional include') - expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked)) - - const repeatedOptIn = await runInstaller(fixture, fixture.linked, { - DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1', - }) - expect(repeatedOptIn.status, repeatedOptIn.stderr).toBe(0) - } - }) - it('restores the previous hook lookup when Lefthook installation fails', async () => { const fixture = createFixture() const common = commonDirectory(fixture) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 9903a7ffdc..e95c086c91 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree` 或 `core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user",