Просмотр исходного кода

Merge compact-tool-pairing into token-meter-service

# Conflicts:
#	docs/event-producer-consumer.md
Hypatia May 2 месяцев назад
Родитель
Сommit
e55e96eec6
95 измененных файлов с 1055 добавлено и 909 удалено
  1. 22 7
      docs/config-catalog.md
  2. 5 5
      docs/cordis-catalog/services.md
  3. 0 1
      docs/core-data-structures/bash.md
  4. 1 1
      docs/core-data-structures/core.md
  5. 2 1
      docs/core-data-structures/tools.md
  6. 2 12
      docs/core-data-structures/web.md
  7. 4 4
      docs/event-producer-consumer.md
  8. 10 1
      docs/module-graph.md
  9. 1 1
      docs/rfc/INDEX.md
  10. 16 31
      docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
  11. 2 3
      docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
  12. 1 1
      docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
  13. 6 11
      docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md
  14. 1 1
      docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md
  15. 1 1
      examples/AGENTS.md
  16. 10 0
      examples/acp-agent/cordis.snapshot.yml
  17. 15 80
      examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts
  18. 16 78
      examples/coding-agent/tests/keyless-smoke.e2e.ts
  19. 15 82
      examples/cordis-agent/tests/keyless-smoke.e2e.ts
  20. 22 90
      examples/echo-agent/tests/echo.e2e.ts
  21. 9 0
      knip.json
  22. 1 1
      packages/README.md
  23. 2 0
      packages/bash/bash-local/README.md
  24. 0 4
      packages/bash/bash-local/src/index.ts
  25. 5 22
      packages/bash/bash-local/src/run.ts
  26. 4 12
      packages/bash/bash-local/tests/run.spec.ts
  27. 0 1
      packages/bash/bash/src/types.ts
  28. 0 1
      packages/bash/bash/tests/service.spec.ts
  29. 2 0
      packages/bash/tool-bash/README.md
  30. 2 72
      packages/bash/tool-bash/src/index.ts
  31. 92 0
      packages/bash/tool-bash/src/render.ts
  32. 1 3
      packages/bash/tool-bash/tests/tools.spec.ts
  33. 9 17
      packages/cordis/tool-cordis/src/api-catalog.ts
  34. 2 1
      packages/core/agent-loop/src/loop.ts
  35. 2 4
      packages/core/agent-loop/tests/contract-regressions.spec.ts
  36. 1 1
      packages/core/system-prompt/src/index.ts
  37. 1 1
      packages/core/tools/README.md
  38. 6 14
      packages/core/tools/src/index.ts
  39. 1 3
      packages/core/tools/tests/scoped.spec.ts
  40. 8 29
      packages/core/tools/tests/tools.spec.ts
  41. 2 1
      packages/support/README.md
  42. 3 3
      packages/support/invariants/tests/invariants.spec.ts
  43. 17 0
      packages/support/loader-smoke/README.md
  44. 33 0
      packages/support/loader-smoke/package.json
  45. 117 0
      packages/support/loader-smoke/src/index.ts
  46. 4 0
      packages/support/loader-smoke/tests/fixtures/fail.ts
  47. 4 0
      packages/support/loader-smoke/tests/fixtures/hang.ts
  48. 16 0
      packages/support/loader-smoke/tests/fixtures/success.ts
  49. 61 0
      packages/support/loader-smoke/tests/loader-smoke.spec.ts
  50. 11 0
      packages/support/loader-smoke/tsconfig.json
  51. 2 5
      packages/timeout/timeout-policy/src/index.ts
  52. 4 14
      packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
  53. 2 1
      packages/ui/README.md
  54. 1 1
      packages/ui/stdio-agent/README.md
  55. 4 2
      packages/ui/stdio-agent/package.json
  56. 4 4
      packages/ui/stdio-agent/src/index.ts
  57. 1 0
      packages/ui/stdio-agent/tests/built-bin.e2e.ts
  58. 3 0
      packages/ui/stdio-agent/tsconfig.json
  59. 42 0
      packages/ui/stdio/README.md
  60. 42 0
      packages/ui/stdio/package.json
  61. 29 5
      packages/ui/stdio/src/index.ts
  62. 19 0
      packages/ui/stdio/tests/plugin-shape.spec.ts
  63. 2 2
      packages/ui/stdio/tests/readline.spec.ts
  64. 50 1
      packages/ui/stdio/tests/stdio.spec.ts
  65. 30 0
      packages/ui/stdio/tsconfig.json
  66. 1 1
      packages/web/tool-web/README.md
  67. 1 1
      packages/web/tool-web/src/fetch.ts
  68. 1 1
      packages/web/tool-web/src/search.ts
  69. 13 6
      packages/web/tool-web/tests/integration.spec.ts
  70. 34 34
      packages/web/tool-web/tests/tool-web.spec.ts
  71. 2 3
      packages/web/web-fetch-local/README.md
  72. 12 7
      packages/web/web-fetch-local/src/index.ts
  73. 7 11
      packages/web/web-fetch-local/src/provider.ts
  74. 12 11
      packages/web/web-fetch-local/tests/fetch-local.spec.ts
  75. 2 2
      packages/web/web-search-deepseek/README.md
  76. 11 13
      packages/web/web-search-deepseek/src/provider.ts
  77. 0 1
      packages/web/web-search-deepseek/tests/deepseek.e2e.ts
  78. 18 25
      packages/web/web-search-deepseek/tests/deepseek.spec.ts
  79. 2 2
      packages/web/web-search-exa/README.md
  80. 11 14
      packages/web/web-search-exa/src/provider.ts
  81. 0 1
      packages/web/web-search-exa/tests/exa.e2e.ts
  82. 11 18
      packages/web/web-search-exa/tests/exa.spec.ts
  83. 2 2
      packages/web/web-search-perplexity/README.md
  84. 9 14
      packages/web/web-search-perplexity/src/provider.ts
  85. 0 1
      packages/web/web-search-perplexity/tests/perplexity.e2e.ts
  86. 15 21
      packages/web/web-search-perplexity/tests/perplexity.spec.ts
  87. 5 5
      packages/web/web/README.md
  88. 10 14
      packages/web/web/src/index.ts
  89. 10 42
      packages/web/web/src/types.ts
  90. 20 22
      packages/web/web/tests/web.spec.ts
  91. 38 0
      pnpm-lock.yaml
  92. 0 1
      scripts/type-equiv.manifest.json
  93. 1 0
      scripts/verify-package-readme-model-experience.ts
  94. 2 0
      tsconfig.build.json
  95. 2 0
      tsconfig.json

+ 22 - 7
docs/config-catalog.md

@@ -140,7 +140,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bash/bash-local/src/index.ts:21`](../packages/bash/bash-local/src/index.ts)
+Source: [`packages/bash/bash-local/src/index.ts:18`](../packages/bash/bash-local/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-sandbox`
 
@@ -597,6 +597,22 @@ export interface Config {
 
 Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts)
 
+## `@deepseek-ai/dsh-stdio`
+
+Requires: `agents` · `userInteraction`
+
+```ts config-catalog
+/** Serializable plugin configuration (cordis-native, schemastery). */
+export interface Config {
+  /** Banner printed once on start, before the first `> ` prompt. */
+  welcome?: string
+  /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
+  agent?: string
+}
+```
+
+Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts)
+
 ## `@deepseek-ai/dsh-stdio-agent`
 
 ```ts config-catalog
@@ -976,7 +992,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:308`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts)
 
 ## `@deepseek-ai/dsh-user-approval`
 
@@ -1026,7 +1042,7 @@ export interface WebServiceConfig {
 }
 ```
 
-Source: [`packages/web/web/src/index.ts:59`](../packages/web/web/src/index.ts)
+Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-fetch-local`
 
@@ -1041,10 +1057,8 @@ export interface Config {
   maxResponseBytes?: number
   /** Maximum decoded body length in characters. */
   maxBodyChars?: number
-  /** Default fetch timeout in milliseconds. */
+  /** Default fetch timeout in milliseconds, within Node's timer range. */
   timeoutMs?: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs?: number
   /** Maximum number of same-origin redirect hops to follow. */
   maxRedirects?: number
   /** `User-Agent` header sent on every request. */
@@ -1052,7 +1066,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts)
+Source: [`packages/web/web-fetch-local/src/index.ts:36`](../packages/web/web-fetch-local/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-search-deepseek`
 
@@ -1187,6 +1201,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts))
+- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
 - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))

+ 5 - 5
docs/cordis-catalog/services.md

@@ -266,7 +266,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
 
 Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:364`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.userInteraction` — `UserInteractionService`
 
@@ -285,7 +285,7 @@ The web access service. Registered as `ctx.web` (one instance per context).
 
 Selection semantics (resolved at execution time, never order-dependent):
 
-- A configured id that is registered and `status().available` → that provider.
+- A configured id that is registered and `available()` → that provider.
 - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
 - A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
 - No id configured, exactly one registered usable provider → that provider.
@@ -295,11 +295,11 @@ Selection semantics (resolved at execution time, never order-dependent):
 ```ts cordis-catalog
 registerSearchProvider(provider: WebSearchProvider): () => void
 registerFetchProvider(provider: WebFetchProvider): () => void
-async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
-async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
+async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 ```
 
-Source: [`packages/web/web/src/index.ts:78`](../../packages/web/web/src/index.ts)
+Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts)
 
 ## `ctx.workflows` — `WorkflowService` (abstract seam)
 

+ 0 - 1
docs/core-data-structures/bash.md

@@ -202,7 +202,6 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT
 ```ts type-equiv
 interface BashTask {
   readonly id: BashTaskId
-  readonly command: string
   status: BashTaskStatus
   /** Exit code once finished (null = killed by signal / still running). */
   exitCode: number | null

+ 1 - 1
docs/core-data-structures/core.md

@@ -32,7 +32,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
 | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
 | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
 | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
-| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
+| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
 | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
 
 > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.

+ 2 - 1
docs/core-data-structures/tools.md

@@ -137,7 +137,6 @@ type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
 
 ```ts type-equiv
 interface ToolExecutionResult {
-  callId: CallId
   content: ContentBlock[]
   isError: boolean
   /**
@@ -167,6 +166,8 @@ interface ToolExecutionResult {
 }
 ```
 
+The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity.
+
 The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append.
 
 Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:

+ 2 - 12
docs/core-data-structures/web.md

@@ -25,8 +25,6 @@ interface WebSearchRequest {
 
 ```ts type-equiv
 interface WebSearchResult {
-  readonly providerId: string
-  readonly query: string
   readonly content?: string
   readonly sources: readonly WebSearchSource[]
   readonly truncated: boolean
@@ -49,7 +47,6 @@ interface WebSearchSource {
 ```ts type-equiv
 interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 ```
 
@@ -57,7 +54,6 @@ HTTP status is part of the fetched resource state, not automatically a failure:
 
 ```ts type-equiv
 interface WebFetchResult {
-  readonly providerId: string
   readonly url: string
   readonly statusCode: number
   readonly body: WebFetchBody
@@ -73,15 +69,9 @@ type WebFetchBody =
   | { readonly kind: 'text'; readonly content: string }
 ```
 
-## Provider status
-
-A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message.
+## Provider availability
 
-```ts type-equiv
-type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-```
+A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
 
 Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
 

+ 4 - 4
docs/event-producer-consumer.md

@@ -7,8 +7,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
 | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
 | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
 | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../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) |
 | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent), [`token-meter`](../packages/llm/token-meter) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 10 - 1
docs/module-graph.md

@@ -90,6 +90,7 @@ flowchart TD
     pkg_acp_snapshot["acp-snapshot"]
     pkg_invariants["invariants"]
     pkg_llm_replay["llm-replay"]
+    pkg_loader_smoke["loader-smoke"]
     pkg_subagent_mock["subagent-mock"]
   end
   subgraph group_ui["packages/ui"]
@@ -99,6 +100,7 @@ flowchart TD
     pkg_jsonrpc["jsonrpc"]
     pkg_jsonrpc_agent["jsonrpc-agent"]
     pkg_permission["permission"]
+    pkg_stdio["stdio"]
     pkg_stdio_agent["stdio-agent"]
     pkg_tool_ask_user["tool-ask-user"]
     pkg_user_approval["user-approval"]
@@ -209,6 +211,10 @@ flowchart TD
   pkg_permission --> pkg_sandbox
   pkg_permission --> pkg_session
   pkg_permission --> pkg_user_approval
+  pkg_stdio --> pkg_agent
+  pkg_stdio --> pkg_llm
+  pkg_stdio --> pkg_session
+  pkg_stdio --> pkg_user_interaction
   pkg_agent_loop --> pkg_agent
   pkg_agent_loop --> pkg_llm
   pkg_agent_loop --> pkg_scope
@@ -337,6 +343,7 @@ flowchart TD
   pkg_stdio_agent --> pkg_llm
   pkg_stdio_agent --> pkg_session
   pkg_stdio_agent --> pkg_session_persistence_jsonl
+  pkg_stdio_agent --> pkg_stdio
   pkg_stdio_agent --> pkg_tool_ask_user
   pkg_stdio_agent --> pkg_tools
   pkg_stdio_agent --> pkg_user_interaction
@@ -350,6 +357,7 @@ flowchart TD
 | [`skill`](../packages/skill/skill) | `skill` | — |
 | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — |
 | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
+| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — |
 | [`app-boot`](../packages/ui/app-boot) | `ui` | — |
 | [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — |
 | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
@@ -390,6 +398,7 @@ flowchart TD
 | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
 | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
 | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
+| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -415,4 +424,4 @@ flowchart TD
 | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
+| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |

+ 1 - 1
docs/rfc/INDEX.md

@@ -20,7 +20,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 |---|---|
 | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
 | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
-| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
 | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
 
 ### Architecture
@@ -103,6 +102,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
 | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
 | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 |
+| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
 
 ### Architecture
 

+ 16 - 31
docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md

@@ -64,7 +64,7 @@ flowchart LR
   toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
 ```
 
-`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages.
+`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
 
 Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
 
@@ -77,52 +77,42 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
 ```ts
 interface WebSearchProvider {
   readonly id: string
-  status(): WebProviderStatus
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
+  available(): boolean
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
 }
 
 interface WebFetchProvider {
   readonly id: string
-  status(): WebProviderStatus
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+  available(): boolean
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 
 interface WebService {
   registerSearchProvider(provider: WebSearchProvider): () => void
   registerFetchProvider(provider: WebFetchProvider): () => void
 
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
-}
-
-interface WebExecContext {
-  readonly signal?: AbortSignal
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 ```
 
-`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`.
+The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`.
 
 Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
 
-## Provider status and selection
-
-Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls.
+## Provider availability and selection
 
-`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
+Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls.
 
-`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
+`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
 
-```ts
-type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-```
+The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
 
 Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
 
 | Situation | Execution behavior |
 |---|---|
-| A configured provider id is registered and `status().available === true` | runs that provider |
+| A configured provider id is registered and `available() === true` | runs that provider |
 | A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
 | A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
 | No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
@@ -184,8 +174,6 @@ interface WebSearchRequest {
 }
 
 interface WebSearchResult {
-  readonly providerId: string
-  readonly query: string
   readonly content?: string
   readonly sources: readonly WebSearchSource[]
   readonly truncated: boolean
@@ -212,20 +200,17 @@ The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `l
 The seam request stays smaller than OpenCode's model-facing tool:
 
 - `url`: required HTTP(S) URL.
-- `timeoutMs`: optional positive number capped by the provider.
 
-The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
+The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
 
 HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
 
 ```ts
 interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 
 interface WebFetchResult {
-  readonly providerId: string
   readonly url: string
   readonly statusCode: number
   readonly body: WebFetchBody
@@ -257,11 +242,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
 
 `dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
 
-`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
+`dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
 
 Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
 
-Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
+Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
 
 The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
 

+ 2 - 3
docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md

@@ -57,9 +57,8 @@ Signal replacement is by **in-place mutation of `exec.signal`**, not by passing
 `timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is:
 
 ```ts ignore-check
-function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
+function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
   return {
-    callId,
     content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
     isError: true,
     error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
@@ -75,7 +74,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
 
 `web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
 
-`dsh-web-fetch-local` keeps a provider-level timeout (`timeoutMs`/`maxTimeoutMs`) as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
+`dsh-web-fetch-local` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
 
 `bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable.
 

+ 1 - 1
docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md

@@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module-
 
 ## Decision
 
-The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam. Per-file tests cover EOF, rendering, disposal, and piped-versus-TTY behavior without replacing process globals. It retains the named Cordis plugin export shape consumed by the app; an `unwrapExports` assertion and keyless Loader smokes guard both the package and composed entry paths.
+The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash).
 
 The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module.
 

+ 6 - 11
docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md → docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md

@@ -1,6 +1,6 @@
 # RFC: Prune unused web seam fields
 
-Status: proposed
+Status: implemented
 
 ## Problem
 
@@ -8,23 +8,18 @@ The web capability carries request/result/status values that every shipped imple
 
 `WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists.
 
-## Proposal
+## Decision
 
-Remove the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Shrink provider status to availability alone, preferably a boolean-returning method if that produces the clearest seam. Remove per-request fetch timeout, `maxTimeoutMs`, and their clamp/validation branches while retaining the provider's configurable default timeout and tool-level deadline. Replace `WebExecContext` with a direct optional `AbortSignal` parameter.
+The web seam omits the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Providers expose availability as a boolean-returning method. Fetch requests have no per-request timeout or `maxTimeoutMs` clamp; the local provider retains its configurable default timeout and the tool retains its own deadline. Provider methods receive a direct optional `AbortSignal` instead of a one-field `WebExecContext` wrapper.
 
-Update all web implementations, the model-facing tool, package READMEs/JSDoc, type-equivalence records, and tests. Keep the interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and all safety limits.
+All web implementations and the model-facing tool use the smaller contract. The interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and safety limits remain.
 
 ## Alternatives considered
 
 **Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object.
 
-## Acceptance criteria
+## Consequences
 
-- Every retained web request/result/status field has a production reader or is required to execute the provider request.
-- Tool-visible search/fetch output, provider fallback, abort behavior, configured timeout backstop, truncation, and citations remain covered.
-- No `maxTimeoutMs`, request-timeout precedence branch, or one-field execution-context wrapper remains.
-- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass.
-
-## Risks
+Every retained web request/result field is consumed by production code or required to execute the provider request. Tool-visible search/fetch output, provider fallback, abort behavior, the configured timeout backstop, truncation, and citations remain covered without a request-timeout precedence branch or execution-context wrapper.
 
 Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound.

+ 1 - 1
docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md

@@ -26,7 +26,7 @@ Amend the session-surface and reconstructable-request RFCs where they describe t
 
 ## Acceptance criteria
 
-- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain, while the separate public `invalidate()` deletion stays owned by the dead-surface RFC.
+- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain.
 - Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains.
 - A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths.
 - New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass.

+ 1 - 1
examples/AGENTS.md

@@ -13,7 +13,7 @@ Each example has both:
 
 Mock-only examples require only the keyless tier; state that exception in the test.
 
-Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `--expose-internals` when loading HMR.
+Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions.
 
 Do not inventory example tests here; the `tests/` trees and root scripts are authoritative.
 

+ 10 - 0
examples/acp-agent/cordis.snapshot.yml

@@ -15,6 +15,16 @@
       - id: llm-deepseek
         name: '@deepseek-ai/dsh-llm-deepseek'
         disabled: true
+      - id: sandbox
+        name: '@deepseek-ai/dsh-sandbox-local'
+        config:
+          runnerCommand:
+            - bash
+            - -c
+            - while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
+            - passthrough-runner
+          runnerFailureSignatures:
+            - 'passthrough-runner: profile rejected'
       - insert:
           - id: llm-replay
             name: '@deepseek-ai/dsh-llm-replay'

+ 15 - 80
examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts

@@ -1,92 +1,27 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for the Code Mode overlay: boot the real example through the
- * `@deepseek-ai/dsh-stdio-agent` bin against `code-mode.cordis.yml` (the cordis Loader,
- * `unwrapExports`, the include patches over ./cordis.yml, the worker-thread code runtime, and
- * the registry in `mode: code`), then close stdin with no prompt and assert the Code Mode
- * banner + a clean exit. A dummy key satisfies adapter boot, but no prompt means
- * no model call; the with-key proof lives in `code-mode.e2e.ts`.
+ * Keyless Loader-path smoke for the Code Mode overlay: boot the real include
+ * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without
+ * a prompt and assert the banner. No model or `run_code` turn runs.
  */
 
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
-// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
-// the repo, so point it at the repo tsconfig.
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
-// 30s still detects a wedged child.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-let child: ChildProcessWithoutNullStreams | undefined
-let workdir: string | undefined
-
-afterEach(async () => {
-  if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
-  child = undefined
-  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
-  workdir = undefined
-})
-
-async function bootAndEof(): Promise<{ stdout: string; code: number }> {
-  workdir = await mkdtemp(join(tmpdir(), 'code-mode-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
-          // No prompt is sent, so the adapter never streams — no network call.
-          DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
-        },
-        stdio: ['pipe', 'pipe', 'pipe'],
-      },
-    )
-    child = proc
-    let stdout = ''
-    let stderr = ''
-    proc.stdout.setEncoding('utf8')
-    proc.stdout.on('data', (chunk: string) => { stdout += chunk })
-    proc.stderr.setEncoding('utf8')
-    proc.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-    const timer = setTimeout(() => {
-      proc.kill('SIGKILL')
-      reject(new Error(`code-mode overlay did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`code-mode overlay exited ${code}. stderr:\n${stderr}`))
-    })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-
-    // No prompt — just EOF, so the stdio UI exits without ever running a turn.
-    proc.stdin.end()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => {
   it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => {
-    const { stdout, code } = await bootAndEof()
-    expect(code).toBe(0)
+    const { stdout } = await runLoaderSmoke({
+      label: 'code-mode overlay',
+      tempDirPrefix: 'code-mode-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('code-mode agent ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 16 - 78
examples/coding-agent/tests/keyless-smoke.e2e.ts

@@ -1,90 +1,28 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Boots the real example through the stdio bin and `cordis.yml`, covering Loader,
- * `unwrapExports`, the full plugin tree, the agent-core bundle, and the readline module.
- * A dummy key permits startup; closing stdin before a prompt prevents network calls,
- * while with-key suites cover product behavior.
+ * Keyless Loader-path smoke for examples/coding-agent: boot the real example
+ * through the stdio-agent bin and its `cordis.yml`, then close stdin without a
+ * prompt and assert the banner. The dummy key satisfies adapter construction;
+ * immediate EOF guarantees there is no model call.
  */
 
-// TODO(loader-smoke-harness): share spawn/tempdir/timeout/EOF setup with the other keyless smoke tests.
-// The temp-cwd child needs absolute bin and config paths.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-// The temp cwd cannot discover the root tsconfig used for unbuilt package aliases.
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// Allow cold Loader startup under parallel load while still detecting hangs.
-const PROCESS_TIMEOUT_MS = 30_000
-// Let the child timeout report captured output before Vitest aborts.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-let child: ChildProcessWithoutNullStreams | undefined
-let workdir: string | undefined
-
-afterEach(async () => {
-  if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
-  child = undefined
-  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
-  workdir = undefined
-})
-
-async function bootAndEof(): Promise<{ stdout: string; code: number }> {
-  workdir = await mkdtemp(join(tmpdir(), 'coding-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:repl).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
-          // No prompt is sent, so the adapter never streams — no network call.
-          DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
-          DSH_HOME: join(cwd, '.dsh'),
-          DSH_AGENTS_HOME: join(cwd, '.agents'),
-        },
-        stdio: ['pipe', 'pipe', 'pipe'],
-      },
-    )
-    child = proc
-    let stdout = ''
-    let stderr = ''
-    proc.stdout.setEncoding('utf8')
-    proc.stdout.on('data', (chunk: string) => { stdout += chunk })
-    proc.stderr.setEncoding('utf8')
-    proc.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-    const timer = setTimeout(() => {
-      proc.kill('SIGKILL')
-      reject(new Error(`coding-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`coding-agent exited ${code}. stderr:\n${stderr}`))
-    })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-
-    // No prompt — just EOF, so the stdio UI exits without ever running a turn.
-    proc.stdin.end()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => {
   it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => {
-    const { stdout, code } = await bootAndEof()
-    expect(code).toBe(0)
+    const { stdout } = await runLoaderSmoke({
+      label: 'coding-agent',
+      tempDirPrefix: 'coding-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('agent REPL ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 15 - 82
examples/cordis-agent/tests/keyless-smoke.e2e.ts

@@ -1,94 +1,27 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for examples/cordis-agent: boot the real example through the
- * `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` — the cordis Loader,
- * `unwrapExports`, the full plugin tree INCLUDING the `@deepseek-ai/dsh-tool-cordis` package
- * resolved by name (whose `inject` would crash a collapsed export shape at load, see
- * docs/postmortem/0001) — then close stdin with no prompt and assert the ready banner + a
- * clean exit. A dummy key satisfies adapter boot, but no prompt means no network
- * call; `cordis-tools.e2e.ts` owns the with-key product proof.
+ * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree,
+ * including tool-cordis resolved by package name, then close stdin without a
+ * prompt and assert the banner. The dummy key never reaches a model call.
  */
 
-// The temp-cwd child needs absolute bin and config paths.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
-// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
-// the repo, so point it at the repo tsconfig (root is three levels up).
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
-// 30s still detects a wedged child.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-let child: ChildProcessWithoutNullStreams | undefined
-let workdir: string | undefined
-
-afterEach(async () => {
-  if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
-  child = undefined
-  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
-  workdir = undefined
-})
-
-async function bootAndEof(): Promise<{ stdout: string; code: number }> {
-  workdir = await mkdtemp(join(tmpdir(), 'cordis-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:cordis).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
-          // No prompt is sent, so the adapter never streams — no network call.
-          DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
-        },
-        stdio: ['pipe', 'pipe', 'pipe'],
-      },
-    )
-    child = proc
-    let stdout = ''
-    let stderr = ''
-    proc.stdout.setEncoding('utf8')
-    proc.stdout.on('data', (chunk: string) => { stdout += chunk })
-    proc.stderr.setEncoding('utf8')
-    proc.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-    const timer = setTimeout(() => {
-      proc.kill('SIGKILL')
-      reject(new Error(`cordis-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`cordis-agent exited ${code}. stderr:\n${stderr}`))
-    })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-
-    // No prompt — just EOF, so the stdio UI exits without ever running a turn.
-    proc.stdin.end()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => {
   it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => {
-    const { stdout, code } = await bootAndEof()
-    expect(code).toBe(0)
+    const { stdout } = await runLoaderSmoke({
+      label: 'cordis-agent',
+      tempDirPrefix: 'cordis-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('cordis-agent ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 22 - 90
examples/echo-agent/tests/echo.e2e.ts

@@ -1,111 +1,43 @@
-import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for examples/echo-agent: boot the real example through the
- * `@deepseek-ai/dsh-stdio-agent` bin against this example's `cordis.yml` (the cordis Loader,
- * `unwrapExports`, the whole plugin tree), pipe a script of stdin lines, and assert the
- * rendered stdout. The mock adapter is network-free, making this the complete
- * smoke; inputs cover both the echo-tool round trip and direct-reply branch.
+ * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real
+ * tree uses its deterministic mock model, so this suite is both the boot smoke
+ * and the complete behavior proof for the example.
  */
 
-// The temp-cwd child needs absolute bin and config paths.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-// The temp cwd is outside the repo, so point tsx at the root config that resolves
-// unbuilt workspace packages through `paths`.
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline;
-// 30s still detects a wedged child.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-let child: ChildProcessWithoutNullStreams | undefined
-let workdir: string | undefined
-
-afterEach(async () => {
-  if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
-  child = undefined
-  if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
-  workdir = undefined
-})
-
-/**
- * Boot echo-agent, write `lines` to its stdin, close stdin, and resolve with
- * the full stdout once the process exits (the stdio UI exits on EOF after the
- * agent settles). Rejects on a non-zero exit or the process deadline.
- */
-async function runEcho(lines: string[]): Promise<{ stdout: string; code: number }> {
-  workdir = await mkdtemp(join(tmpdir(), 'echo-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: the example's cordis.yml loads the HMR plugin, which requires it
-      // (mirrors the `demo:echo` script).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          DSH_HOME: join(cwd, '.dsh'),
-          DSH_AGENTS_HOME: join(cwd, '.agents'),
-        },
-        stdio: ['pipe', 'pipe', 'pipe'],
-      },
-    )
-    child = proc
-    let stdout = ''
-    let stderr = ''
-    proc.stdout.setEncoding('utf8')
-    proc.stdout.on('data', (chunk: string) => { stdout += chunk })
-    proc.stderr.setEncoding('utf8')
-    proc.stderr.on('data', (chunk: string) => { stderr += chunk })
-
-    const timer = setTimeout(() => {
-      proc.kill('SIGKILL')
-      reject(new Error(`echo-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`echo-agent exited ${code}. stderr:\n${stderr}`))
-    })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-
-    // Feed the script, then EOF so the stdio UI exits after the agent settles.
-    for (const line of lines) proc.stdin.write(`${line}\n`)
-    proc.stdin.end()
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+
+async function runEcho(stdinLines: readonly string[]): Promise<string> {
+  const { stdout } = await runLoaderSmoke({
+    label: 'echo-agent',
+    tempDirPrefix: 'echo-smoke-',
+    binScript,
+    configPath,
+    tsconfigPath,
+    stdinLines,
   })
+  return stdout
 }
 
 describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => {
   it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => {
-    const { stdout, code } = await runEcho([])
-    expect(code).toBe(0)
-    expect(stdout).toContain('echo-agent ready.')
-  }, TEST_TIMEOUT_MS)
+    expect(await runEcho([])).toContain('echo-agent ready.')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('runs the echo tool round-trip for an "echo …" line', async () => {
-    const { stdout } = await runEcho(['echo hello world'])
-    // mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases.
+    const stdout = await runEcho(['echo hello world'])
     expect(stdout).toContain('[tool call] echo')
     expect(stdout).toContain('[tool result] ECHO: HELLO WORLD')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('streams a direct canned reply for a non-echo line', async () => {
-    const { stdout } = await runEcho(['just chatting'])
-    // The direct-response branch of mock-llm.ts quotes the input back.
+    const stdout = await runEcho(['just chatting'])
     expect(stdout).toContain('just chatting')
     expect(stdout).not.toContain('[tool call]')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 9 - 0
knip.json

@@ -45,6 +45,11 @@
       "project": ["src/**/*.ts", "tests/**/*.ts"],
       "ignoreDependencies": ["cordis"]
     },
+    "packages/support/loader-smoke": {
+      "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"],
+      "ignoreDependencies": ["cordis"]
+    },
     "packages/core/agent-loop": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
@@ -85,6 +90,10 @@
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
     },
+    "packages/ui/stdio": {
+      "entry": ["tests/**/*.spec.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"]
+    },
     "packages/ui/jsonrpc-agent": {
       "project": ["src/**/*.ts"]
     },

+ 1 - 1
packages/README.md

@@ -28,7 +28,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
 | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
-| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
+| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
 | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
 
 The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).

+ 2 - 0
packages/bash/bash-local/README.md

@@ -2,6 +2,8 @@
 
 Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
 
+The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
+
 ## Config
 
 ```yaml

+ 0 - 4
packages/bash/bash-local/src/index.ts

@@ -14,9 +14,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { DEFAULT_GRACE_MS, runBash } from './run.ts'
 import type { RunInternals, RunningBash } from './run.ts'
 
-export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
-export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
-
 /** Plugin config (all optional — `static Config` supplies the defaults). */
 export interface Config {
   /** Default working directory for commands (default: process.cwd()). */
@@ -170,7 +167,6 @@ export class LocalBashExecutor extends BashExecutor {
     const id = BashTaskId(`bash-${this.nextTaskId++}`)
     const task: TrackedTask = {
       id,
-      command: spec.command,
       status: 'running',
       exitCode: null,
       signal: null,

+ 5 - 22
packages/bash/bash-local/src/run.ts

@@ -184,27 +184,6 @@ export class OutputCollector {
     writeSync(this.spillFd, chunk)
   }
 
-  // TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
-  // the bottom of this file) and `totalBytes` is read only by a test. The live
-  // background-poll path goes through `readFrom()`, so inline snapshot() into
-  // finalize() and drop or privatize the totalBytes getter.
-  /**
-   * Read the collected tail without finalizing (the final-result snapshot).
-   * @returns the retained tail text, the truncation flag, and the spill path when one was created.
-   */
-  snapshot(): CollectedOutput {
-    return {
-      text: Buffer.concat(this.chunks).toString('utf8'),
-      truncated: this.dropped,
-      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
-    }
-  }
-
-  /** Total bytes ever pushed (including bytes dropped from memory). */
-  get totalBytes(): number {
-    return this.total
-  }
-
   /**
    * Incremental read in whole-stream byte coordinates: returns everything
    * pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -243,7 +222,11 @@ export class OutputCollector {
       }
       this.spillFd = undefined
     }
-    return this.snapshot()
+    return {
+      text: Buffer.concat(this.chunks).toString('utf8'),
+      truncated: this.dropped,
+      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
+    }
   }
 }
 

+ 4 - 12
packages/bash/bash-local/tests/run.spec.ts

@@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
-import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
-import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
+import { killGroup, OutputCollector, runBash } from '../src/run.ts'
+import type { RunningBash } from '../src/run.ts'
 
 const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
 vi.mock('node:fs', async (importOriginal) => {
@@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
 async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
   const deadline = Date.now() + timeoutMs
   while (Date.now() < deadline) {
-    if (running.stdout.snapshot().text.includes(expected)) return
+    if (running.stdout.readFrom(0).text.includes(expected)) return
     await new Promise(resolve => setTimeout(resolve, 20))
   }
   throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
@@ -295,19 +295,11 @@ describe('OutputCollector', () => {
     expect(third.spillPath).toBeDefined()
   })
 
-  it('tracks totalBytes across drops', () => {
-    const collector = new OutputCollector(4, 'test', spillDir)
-    collector.push(Buffer.from('aaaa'))
-    collector.push(Buffer.from('bbbb'))
-    expect(collector.totalBytes).toBe(8)
-    expect(collector.finalize().text).toBe('bbbb')
-  })
-
   it('contains close failures and drops the spill path', () => {
     const collector = new OutputCollector(4, 'closefail', spillDir)
     collector.push(Buffer.from('aaaa'))
     collector.push(Buffer.from('bbbb'))
-    expect(collector.snapshot().spillPath).toBeDefined()
+    expect(collector.readFrom(0).spillPath).toBeDefined()
 
     failNextClose.value = true
     let out: ReturnType<typeof collector.finalize>

+ 0 - 1
packages/bash/bash/src/types.ts

@@ -215,7 +215,6 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
 /** A tracked background task handle. */
 export interface BashTask {
   readonly id: BashTaskId
-  readonly command: string
   status: BashTaskStatus
   /** Exit code once finished (null = killed by signal / still running). */
   exitCode: number | null

+ 0 - 1
packages/bash/bash/tests/service.spec.ts

@@ -34,7 +34,6 @@ class StubExecutor extends BashExecutor {
   start(spec: BashExecSpec): BashTask {
     const task: BashTask = {
       id: BashTaskId(`stub-${this.tasks.size + 1}`),
-      command: spec.command,
       status: 'running',
       exitCode: null,
       signal: null,

+ 2 - 0
packages/bash/tool-bash/README.md

@@ -4,6 +4,8 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
 
 Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
 
+The package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests.
+
 The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching).
 
 ## Tools

+ 2 - 72
packages/bash/tool-bash/src/index.ts

@@ -21,7 +21,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-user-approval'
 import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
 import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
-import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
+import type { BashTask } from '@deepseek-ai/dsh-bash'
+import { parseExitStatus, renderResult } from './render.ts'
 
 export const name = 'tool-bash'
 export const inject = ['tools', 'bash', 'systemPrompt']
@@ -118,65 +119,6 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
     + 'it — but it does not forbid attempting or escalating other commands later.'
 }
 
-/** Append the truncation notice (with the full-output spill path) to a stream's text. */
-function streamText(output: CollectedOutput): string {
-  if (!output.truncated) return output.text
-  return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
-}
-
-/**
- * Shape one finished run into model-visible stdout, marked stderr, and status
- * facts. Non-zero exits and sandbox denials remain ordinary results; only
- * infrastructure failure or abort makes the tool call itself fail.
- *
- * @param result - the completed foreground run from the executor.
- * @param escalationModes - the escalation targets this composition advertises; non-empty
- *   adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
- * @returns the model-facing text: output body (or `(no output)`), then any
- *   timeout/signal/exit markers, each on its own line.
- */
-export function renderResult(
-  result: BashRunResult,
-  escalationModes: readonly SandboxMode[] = [],
-): string {
-  const out = streamText(result.stdout)
-  const err = streamText(result.stderr)
-
-  let body = out
-  if (err.length > 0) {
-    // Single newline between sections (stdout usually ends with one already).
-    if (body.length > 0 && !body.endsWith('\n')) body += '\n'
-    body += `[stderr]\n${err}`
-  }
-  if (body.length === 0) body = '(no output)'
-
-  const markers: string[] = []
-  // Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial,
-  // like a timeout, remains a reported fact for the model to handle.
-  if (result.sandbox?.denied) {
-    markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
-    // Add the retry hint only when the schema advertises escalation, before
-    // the final exit marker.
-    if (escalationModes.length > 0) {
-      markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
-    }
-  }
-  // Timeout is reported independently of how the process actually ended: a
-  // command can trap SIGTERM and exit 0 after our timer fired (e.g.
-  // `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
-  // signal:null — the model must still see that the command was cut short.
-  if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
-  if (result.signal !== null) {
-    markers.push(`[killed by signal: ${result.signal}]`)
-  } else if (result.exitCode !== 0) {
-    markers.push(`[exit code: ${result.exitCode}]`)
-  }
-  if (markers.length === 0) return body
-
-  if (!body.endsWith('\n')) body += '\n'
-  return body + markers.join('\n')
-}
-
 // Pure tool-owned presentation used for both live events and replay.
 
 /**
@@ -224,18 +166,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
   return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
 }
 
-/**
- * Recover exit status from the final marked line emitted by {@link renderResult}.
- * A program whose own final line exactly mimics a marker remains ambiguous for UI display.
- */
-function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
-  const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
-  if (signal?.[1] !== undefined) return { signal: signal[1] }
-  const exit = /\n\[exit code: (\d+)\]$/.exec(text)
-  if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
-  return { exitCode: 0 }
-}
-
 /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
 function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
   return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }

+ 92 - 0
packages/bash/tool-bash/src/render.ts

@@ -0,0 +1,92 @@
+/**
+ * Model-facing result rendering for the bash tool.
+ *
+ * @module @deepseek-ai/dsh-tool-bash/render
+ */
+
+import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
+import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
+
+/** Append the truncation notice (with the full-output spill path) to a stream's text. */
+function streamText(output: CollectedOutput): string {
+  if (!output.truncated) return output.text
+  return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
+}
+
+/**
+ * Shape one finished run into the text the model sees: stdout, then a marked
+ * stderr section, then exit-status markers. Non-zero exits are REPORTED, not
+ * errored — the model decides how to react; only infrastructure failures
+ * (spawn errors, aborts) surface as isError results.
+ * @param result - the completed foreground run from the executor.
+ * @param escalationModes - the escalation targets this composition advertises;
+ *   non-empty adds the same-turn escalation hint after a denial marker
+ *   (default `[]`: no hint).
+ * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
+ */
+export function renderResult(
+  result: BashRunResult,
+  escalationModes: readonly SandboxMode[] = [],
+): string {
+  const out = streamText(result.stdout)
+  const err = streamText(result.stderr)
+
+  let body = out
+  if (err.length > 0) {
+    // Single newline between sections (stdout usually ends with one already).
+    if (body.length > 0 && !body.endsWith('\n')) body += '\n'
+    body += `[stderr]\n${err}`
+  }
+  if (body.length === 0) body = '(no output)'
+
+  const markers: string[] = []
+  // The sandbox marker precedes the exit-status markers so `[exit code: N]`
+  // stays the LAST line (exitStatus() anchors its parse there). Denial is a
+  // reported fact like timeout: the model decides how to react.
+  if (result.sandbox?.denied) {
+    markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
+    // The same-turn nudge lives at the decision point: only when this
+    // composition advertises the fields (a lever is never hinted that the
+    // schema does not offer), and inside the sandbox marker family so the
+    // exit-code marker stays the last line.
+    if (escalationModes.length > 0) {
+      markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
+    }
+  }
+  // Timeout is reported independently of how the process actually ended: a
+  // command can trap SIGTERM and exit 0 after our timer fired (e.g.
+  // `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
+  // signal:null — the model must still see that the command was cut short.
+  if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
+  if (result.signal !== null) {
+    markers.push(`[killed by signal: ${result.signal}]`)
+  } else if (result.exitCode !== 0) {
+    markers.push(`[exit code: ${result.exitCode}]`)
+  }
+  if (markers.length === 0) return body
+
+  if (!body.endsWith('\n')) body += '\n'
+  return body + markers.join('\n')
+}
+
+/**
+ * Recover the structured exit status from a rendered {@link renderResult}
+ * string — the inverse of the status markers it appends. A killed marker
+ * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
+ * means a clean exit 0.
+ *
+ * Replay only retains the rendered content text, not the original
+ * `BashRunResult`, so terminal presentation must recover the exit pill here.
+ * Requiring a leading newline and the end of the string keeps ordinary output
+ * that merely ends with marker-like text from matching unless the final line
+ * is indistinguishable from a real marker.
+ * @param text - rendered model-facing bash result.
+ * @returns the recovered terminal exit code or signal.
+ */
+export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
+  const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
+  if (signal?.[1] !== undefined) return { signal: signal[1] }
+  const exit = /\n\[exit code: (\d+)\]$/.exec(text)
+  if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
+  return { exitCode: 0 }
+}

+ 1 - 3
packages/bash/tool-bash/tests/tools.spec.ts

@@ -19,7 +19,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
 import ApprovalService from '@deepseek-ai/dsh-user-approval'
 import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
-import { renderResult } from '@deepseek-ai/dsh-tool-bash'
+import { renderResult } from '../src/render.ts'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
 
@@ -114,7 +114,6 @@ abstract class TestBashExecutor extends BashExecutor {
 class LossyReadBashExecutor extends TestBashExecutor {
   private readonly task: BashTask = {
     id: BashTaskId('bash-lossy'),
-    command: 'fake',
     status: 'running',
     exitCode: null,
     signal: null,
@@ -1034,7 +1033,6 @@ describe('sandbox rendering', () => {
     class FactsOnlyExecutor extends TestBashExecutor {
       private readonly task: BashTask = {
         id: BashTaskId('bash-facts'),
-        command: 'fake',
         status: 'completed',
         exitCode: 1,
         signal: null,

+ 9 - 17
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -247,8 +247,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       'registerSearchProvider(provider: WebSearchProvider): () => void',
       'registerFetchProvider(provider: WebFetchProvider): () => void',
-      'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>',
-      'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>',
+      'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
+      'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>',
     ],
   },
   {
@@ -582,7 +582,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'BashTask',
-    declaration: 'export interface BashTask {\n    readonly id: BashTaskId;\n    readonly command: string;\n    status: BashTaskStatus;\n    exitCode: number | null;\n    signal: NodeJS.Signals | null;\n    readonly done: Promise<void>;\n    sandbox?: BashSandboxInfo;\n}',
+    declaration: 'export interface BashTask {\n    readonly id: BashTaskId;\n    status: BashTaskStatus;\n    exitCode: number | null;\n    signal: NodeJS.Signals | null;\n    readonly done: Promise<void>;\n    sandbox?: BashSandboxInfo;\n}',
   },
   {
     name: 'BashTaskId',
@@ -1014,7 +1014,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ToolExecutionResult',
-    declaration: 'export interface ToolExecutionResult {\n    callId: CallId;\n    content: ContentBlock[];\n    isError: boolean;\n    error?: ToolErrorInfo;\n    additionalContext?: HookContext;\n    meta?: unknown;\n}',
+    declaration: 'export interface ToolExecutionResult {\n    content: ContentBlock[];\n    isError: boolean;\n    error?: ToolErrorInfo;\n    additionalContext?: HookContext;\n    meta?: unknown;\n}',
   },
   {
     name: 'ToolExecutionToken',
@@ -1068,33 +1068,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'UserInteractionProvider',
     declaration: 'export interface UserInteractionProvider {\n    ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
   },
-  {
-    name: 'WebExecContext',
-    declaration: 'export interface WebExecContext {\n    readonly signal?: AbortSignal;\n}',
-  },
   {
     name: 'WebFetchBody',
     declaration: 'export type WebFetchBody = {\n    readonly kind: \'html\';\n    readonly content: string;\n} | {\n    readonly kind: \'text\';\n    readonly content: string;\n};',
   },
   {
     name: 'WebFetchProvider',
-    declaration: 'export interface WebFetchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}',
+    declaration: 'export interface WebFetchProvider {\n    readonly id: string;\n    available(): boolean;\n    fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
   },
   {
     name: 'WebFetchRequest',
-    declaration: 'export interface WebFetchRequest {\n    readonly url: string;\n    readonly timeoutMs?: number;\n}',
+    declaration: 'export interface WebFetchRequest {\n    readonly url: string;\n}',
   },
   {
     name: 'WebFetchResult',
-    declaration: 'export interface WebFetchResult {\n    readonly providerId: string;\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
-  },
-  {
-    name: 'WebProviderStatus',
-    declaration: 'export type WebProviderStatus = {\n    readonly available: true;\n} | {\n    readonly available: false;\n    readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
+    declaration: 'export interface WebFetchResult {\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
   },
   {
     name: 'WebSearchProvider',
-    declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}',
+    declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    available(): boolean;\n    search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
   },
   {
     name: 'WebSearchRequest',
@@ -1102,7 +1094,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'WebSearchResult',
-    declaration: 'export interface WebSearchResult {\n    readonly providerId: string;\n    readonly query: string;\n    readonly content?: string;\n    readonly sources: readonly WebSearchSource[];\n    readonly truncated: boolean;\n}',
+    declaration: 'export interface WebSearchResult {\n    readonly content?: string;\n    readonly sources: readonly WebSearchSource[];\n    readonly truncated: boolean;\n}',
   },
   {
     name: 'WebSearchSource',

+ 2 - 1
packages/core/agent-loop/src/loop.ts

@@ -574,7 +574,8 @@ async function runStep(
     })
     session.append('tool/result', {
       turn, step,
-      // Preserve transcript pairing even if a post-execute listener returns another id.
+      // Correlation comes from the immutable execution input; the result does
+      // not duplicate this authoritative transcript identity.
       callId: call.id,
       content: result.content,
       isError: result.isError,

+ 2 - 4
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -1109,8 +1109,7 @@ describe('tool result call identity', () => {
 
     // A post-execute listener transforms the result (accept-with-replacement).
     // The loop must still record the tool/result under the model's authoritative
-    // call.id (the loop ignores result.callId — which the registry always sets to
-    // exec.callId anyway — and uses call.id, the model-transcript id).
+    // call.id, which is the immutable identity carried by the execution input.
     ctx.on('tools/post-execute', (exec, _result) => {
       expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
       return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -1120,8 +1119,7 @@ describe('tool result call identity', () => {
     send(agent, 'use tool')
     await waitForIdle(ctx, agent)
 
-    // The logged tool/result.callId is the originating call.id, NOT the
-    // listener's wrong id.
+    // The logged tool/result.callId is the originating call.id.
     const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
     expect(resultEvent?.type).toBe('tool/result')
     if (resultEvent?.type === 'tool/result') {

+ 1 - 1
packages/core/system-prompt/src/index.ts

@@ -226,7 +226,7 @@ export class SystemPrompt extends Service {
   private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
   private readonly toolOrder: string[] | undefined
 
-  constructor(ctx: Context, public config: Config) {
+  constructor(ctx: Context, config: Config) {
     super(ctx, 'systemPrompt')
     this.toolOrder = validateToolOrder(config.toolOrder)
     // Keep harness-owned openers independent of the selected loop plugin.

+ 1 - 1
packages/core/tools/README.md

@@ -36,7 +36,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
 - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
 - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
 - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
-- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
+- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
 - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
 - `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
 - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

+ 6 - 14
packages/core/tools/src/index.ts

@@ -220,7 +220,7 @@ export interface ToolErrorInfo {
  * distinguish it from a tool body's own error.
  */
 export class ToolNotFoundError extends HarnessError {
-  constructor(public readonly toolName: string) {
+  constructor(toolName: string) {
     super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
     this.name = 'ToolNotFoundError'
   }
@@ -228,7 +228,6 @@ export class ToolNotFoundError extends HarnessError {
 
 /** The outcome of one tool call. */
 export interface ToolExecutionResult {
-  callId: CallId
   content: ContentBlock[]
   isError: boolean
   /**
@@ -704,7 +703,7 @@ export class ToolRegistry extends Service {
       }
     } catch (error: unknown) {
       execution = { ...base, arguments: undefined }
-      const result = this.materializeFinalResult(toolErrorResult(callId, error))
+      const result = this.materializeFinalResult(toolErrorResult(error))
       this.notifyResult(execution, result)
       return result
     }
@@ -714,7 +713,7 @@ export class ToolRegistry extends Service {
     } catch (error: unknown) {
       // Outer backstop: a throwing pre/post-execute listener, guard, or the
       // waterfall machinery becomes an isError result, never a turn failure.
-      result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
+      result = this.materializeFinalResult(toolErrorResult(error))
     }
     this.notifyResult(execution, result)
     return result
@@ -739,7 +738,6 @@ export class ToolRegistry extends Service {
       // Every non-grant, including a failed/unavailable approval request, takes
       // the same deny path and still reaches post-policy plus result observers.
       const denied: ToolExecutionResult = {
-        callId: exec.callId,
         content: [{ type: 'text', text: `Error: ${denialReason}` }],
         isError: true,
       }
@@ -770,16 +768,12 @@ export class ToolRegistry extends Service {
           const returned = await tool.execute(exec.arguments, exec)
           const content = Array.isArray(returned) ? returned : returned.content
           const meta = Array.isArray(returned) ? undefined : returned.meta
-          return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
+          return { content, isError: false, ...meta !== undefined ? { meta } : {} }
         } catch (error: unknown) {
-          return toolErrorResult(exec.callId, error)
+          return toolErrorResult(error)
         }
       },
     )
-    if (result.callId !== exec.callId) {
-      throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
-    }
-
     return await this.postExecute(exec, result)
   }
 
@@ -854,7 +848,6 @@ export class ToolRegistry extends Service {
     const additionalContext = decision.additionalContext
     if (decision.kind === 'block') {
       return {
-        callId: result.callId,
         content: decision.feedback,
         isError: true,
         ...additionalContext ? { additionalContext } : {},
@@ -883,10 +876,9 @@ function createExecutionToken(): ToolExecutionToken {
   return Symbol('dsh.tool.execution') as ToolExecutionToken
 }
 
-function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
+function toolErrorResult(error: unknown): ToolExecutionResult {
   const info = errorInfo(error)
   return {
-    callId,
     content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
     isError: true,
     ...info ? { error: info } : {},

+ 1 - 3
packages/core/tools/tests/scoped.spec.ts

@@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => {
 
     expect(reads).toBe(1)
     expect(result).toEqual({
-      callId: CallId('unstable-arguments'),
       content: [{ type: 'text', text: 'ran:t' }],
       isError: false,
     })
@@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => {
     ctx.on('internal/dispatch', (mode, name) => {
       if (name === 'tools/result') dispatchModes.push(mode)
     })
-    ctx.on('tools/execute', async (exec, next) => {
+    ctx.on('tools/execute', async (_exec, next) => {
       await next()
       return {
-        callId: exec.callId,
         content: [{ type: 'text', text: 'outer failure' }],
         isError: true,
       }

+ 8 - 29
packages/core/tools/tests/tools.spec.ts

@@ -80,7 +80,7 @@ describe('ToolRegistry', () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
-    expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
+    expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
   })
 
   it('threads a tool-attached meta (object return form) onto the result', async () => {
@@ -94,7 +94,6 @@ describe('ToolRegistry', () => {
     })
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
     expect(result).toEqual({
-      callId: CallId('c1'),
       content: [{ type: 'text', text: 'ok' }],
       isError: false,
       meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
@@ -111,7 +110,7 @@ describe('ToolRegistry', () => {
       },
     })
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
-    expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
+    expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
     expect('meta' in result).toBe(false)
   })
 
@@ -178,13 +177,12 @@ describe('ToolRegistry', () => {
     })
   })
 
-  it('ToolNotFoundError carries the tool name and a stable code', async () => {
+  it('ToolNotFoundError carries a stable message and code', async () => {
     const { HarnessError } = await import('@deepseek-ai/dsh-llm')
     const err = new ToolNotFoundError('ghost')
     expect(err).toBeInstanceOf(HarnessError)
     expect(err.name).toBe('ToolNotFoundError')
     expect(err.code).toBe('UNKNOWN_TOOL')
-    expect(err.toolName).toBe('ghost')
     expect(err.message).toBe('unknown tool "ghost"')
   })
 
@@ -425,7 +423,7 @@ describe('ToolRegistry', () => {
     ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
 
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
-    expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
+    expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
     // The around seam wraps dispatch; pre gates before it, post runs over its result.
     expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
   })
@@ -526,8 +524,8 @@ describe('ToolRegistry', () => {
       async execute() { dispatched = true; return [] },
     })
 
-    ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
-      ({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
+    ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
+      ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
 
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
     expect(dispatched).toBe(false) // returning without next() skips core dispatch
@@ -537,8 +535,7 @@ describe('ToolRegistry', () => {
   it('preserves additionalContext supplied by an around-dispatch result', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
-    ctx.on('tools/execute', async exec => ({
-      callId: exec.callId,
+    ctx.on('tools/execute', async () => ({
       content: [{ type: 'text', text: 'short-circuited with context' }],
       isError: false,
       additionalContext: {
@@ -556,20 +553,6 @@ describe('ToolRegistry', () => {
     })
   })
 
-  it('normalizes a tools/execute result with the wrong call id', async () => {
-    const ctx = await setup()
-    ctx.tools.register(echoTool)
-    ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
-
-    const result = await ctx.tools.execute({
-      callId: CallId('malformed-shape'), name: 'echo', arguments: {},
-    })
-    expect(result.isError).toBe(true)
-    expect(result.content[0]).toMatchObject({
-      text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
-    })
-  })
-
   it('returns an isError result when a tools/execute listener throws', async () => {
     const ctx = await setup()
     ctx.tools.register(echoTool)
@@ -577,7 +560,6 @@ describe('ToolRegistry', () => {
 
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
     expect(result).toEqual({
-      callId: CallId('c1'),
       content: [{ type: 'text', text: 'Error: wrapper broke' }],
       isError: true,
     })
@@ -593,7 +575,6 @@ describe('ToolRegistry', () => {
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
 
     expect(result).toEqual({
-      callId: CallId('c1'),
       content: [{ type: 'text', text: 'Error: permission hook broke' }],
       isError: true,
     })
@@ -609,7 +590,6 @@ describe('ToolRegistry', () => {
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
 
     expect(result).toEqual({
-      callId: CallId('c1'),
       content: [{ type: 'text', text: 'Error: post hook broke' }],
       isError: true,
     })
@@ -625,7 +605,6 @@ describe('ToolRegistry', () => {
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
 
     expect(result).toMatchObject({
-      callId: CallId('c1'),
       isError: true,
       error: { name: 'HarnessError', code: 'DENIED' },
     })
@@ -1254,7 +1233,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
       },
     }))
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
-    expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
+    expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
   })
 
   it('ToolArgsError carries a stable code and the violation list', () => {

+ 2 - 1
packages/support/README.md

@@ -6,7 +6,8 @@ Packages that exist to serve development, testing, and the examples rather than
 |---|---|---|
 | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
 | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) |
+| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
 | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
 | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
 
-`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
+`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

+ 3 - 3
packages/support/invariants/tests/invariants.spec.ts

@@ -859,9 +859,9 @@ describe('scoped-dispatch invariants', () => {
       ['agent/error', [agent, 1, 0, new Error('x')]],
       ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
       ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
-      ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]],
-      ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
-      ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
+      ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
+      ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
+      ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
     ]
     for (const [event, args] of rows) {
       const subject = agent

+ 17 - 0
packages/support/loader-smoke/README.md

@@ -0,0 +1,17 @@
+# `@deepseek-ai/dsh-loader-smoke`
+
+Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
+
+Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first.
+
+This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`.
+
+## Model Experience
+
+None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request.
+
+## Known Limitations and Deferred Work
+
+- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes.
+- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it.
+- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.

+ 33 - 0
packages/support/loader-smoke/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@deepseek-ai/dsh-loader-smoke",
+  "description": "Shared subprocess harness for keyless real-Loader example smoke tests",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "tsx": "^4.22.4"
+  },
+  "peerDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 117 - 0
packages/support/loader-smoke/src/index.ts

@@ -0,0 +1,117 @@
+/**
+ * Shared subprocess harness for keyless example smokes that boot a real
+ * `cordis.yml` through the stdio-agent bin and Cordis Loader.
+ *
+ * @module @deepseek-ai/dsh-loader-smoke
+ */
+
+import { spawn } from 'node:child_process'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
+const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx'))
+
+/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
+export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
+
+/** Inputs that vary between real-Loader example smokes. */
+export interface LoaderSmokeOptions {
+  /** Human-readable example name used in failure diagnostics. */
+  readonly label: string
+  /** Prefix for the isolated temporary process cwd. */
+  readonly tempDirPrefix: string
+  /** Absolute stdio-agent bin path. */
+  readonly binScript: string
+  /** Absolute real Loader config path. */
+  readonly configPath: string
+  /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */
+  readonly tsconfigPath: string
+  /** Environment overrides layered over the parent and isolated DSH homes. */
+  readonly env?: Readonly<NodeJS.ProcessEnv>
+  /** Lines written to stdin before EOF; omitted means immediate EOF. */
+  readonly stdinLines?: readonly string[]
+  /** Process deadline override for harness tests. */
+  readonly processTimeoutMs?: number
+}
+
+/** Captured output from a Loader smoke that exited successfully. */
+export interface LoaderSmokeResult {
+  /** Complete stdout after clean exit. */
+  readonly stdout: string
+  /** Complete stderr after clean exit. */
+  readonly stderr: string
+}
+
+/**
+ * Boot one real Loader tree from an isolated cwd, write the requested stdin
+ * script, close stdin, and await a clean exit. The helper owns process kill and
+ * temp-directory cleanup on every outcome.
+ * @param options - example paths, environment, stdin, and diagnostic identity.
+ * @returns captured stdout and stderr after a zero exit.
+ */
+export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
+  const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
+  const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
+  try {
+    return await new Promise((resolve, reject) => {
+      const child = spawn(
+        process.execPath,
+        ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
+        {
+          cwd,
+          env: {
+            ...process.env,
+            DSH_HOME: join(cwd, '.dsh'),
+            DSH_AGENTS_HOME: join(cwd, '.agents'),
+            ...options.env,
+            TSX_TSCONFIG_PATH: options.tsconfigPath,
+          },
+          stdio: ['pipe', 'pipe', 'pipe'],
+        },
+      )
+      let stdout = ''
+      let stderr = ''
+      let deferredFailure: Error | undefined
+      child.stdout.setEncoding('utf8')
+      child.stdout.on('data', (chunk: string) => { stdout += chunk })
+      child.stderr.setEncoding('utf8')
+      child.stderr.on('data', (chunk: string) => { stderr += chunk })
+
+      const timer = setTimeout(() => {
+        deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
+        child.kill('SIGKILL')
+      }, processTimeoutMs)
+
+      child.once('exit', (code) => {
+        clearTimeout(timer)
+        if (deferredFailure !== undefined) {
+          reject(deferredFailure)
+        } else if (code === 0) {
+          resolve({ stdout, stderr })
+        } else {
+          reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
+        }
+      })
+
+      // process.execPath and a just-created pipe make these OS-error paths
+      // impractical to induce without replacing the boundary under test.
+      /* v8 ignore start */
+      child.once('error', (error) => {
+        clearTimeout(timer)
+        reject(new Error(`${options.label} failed to start: ${error.message}`))
+      })
+      child.stdin.once('error', (error) => {
+        deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
+        child.kill('SIGKILL')
+      })
+      /* v8 ignore stop */
+
+      child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
+    })
+  } finally {
+    await rm(cwd, { recursive: true, force: true })
+  }
+}

+ 4 - 0
packages/support/loader-smoke/tests/fixtures/fail.ts

@@ -0,0 +1,4 @@
+/** Non-zero subprocess fixture for the Loader-smoke harness. */
+
+console.error('fixture failed')
+process.exitCode = 7

+ 4 - 0
packages/support/loader-smoke/tests/fixtures/hang.ts

@@ -0,0 +1,4 @@
+/** Deadline subprocess fixture for the Loader-smoke harness. */
+
+console.log('fixture hanging')
+setInterval(() => {}, 1_000)

+ 16 - 0
packages/support/loader-smoke/tests/fixtures/success.ts

@@ -0,0 +1,16 @@
+/** Successful subprocess fixture for the Loader-smoke harness. */
+
+let input = ''
+process.stdin.setEncoding('utf8')
+process.stdin.on('data', (chunk: string) => { input += chunk })
+process.stdin.on('end', () => {
+  console.log(JSON.stringify({
+    configPath: process.argv[2],
+    cwd: process.cwd(),
+    dshHome: process.env.DSH_HOME,
+    agentsHome: process.env.DSH_AGENTS_HOME,
+    marker: process.env.LOADER_SMOKE_MARKER,
+    input,
+  }))
+  console.error('fixture stderr')
+})

+ 61 - 0
packages/support/loader-smoke/tests/loader-smoke.spec.ts

@@ -0,0 +1,61 @@
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+
+const configPath = '/tmp/fixture.cordis.yml'
+const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url))
+const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '')
+
+describe('runLoaderSmoke', () => {
+  it('isolates the process, writes stdin, captures output, and removes the cwd', async () => {
+    const result = await runLoaderSmoke({
+      label: 'success fixture',
+      tempDirPrefix: 'loader-smoke-success-',
+      binScript: fixture('success'),
+      configPath,
+      tsconfigPath,
+      env: { LOADER_SMOKE_MARKER: 'present' },
+      stdinLines: ['one', 'two'],
+    })
+    const output = JSON.parse(result.stdout) as {
+      configPath: string
+      cwd: string
+      dshHome: string
+      agentsHome: string
+      marker: string
+      input: string
+    }
+    expect(output).toMatchObject({
+      configPath,
+      marker: 'present',
+      input: 'one\ntwo\n',
+    })
+    expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`)
+    expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`)
+    expect(result.stderr).toContain('fixture stderr')
+    expect(existsSync(output.cwd)).toBe(false)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
+  it('rejects a non-zero exit with captured diagnostics', async () => {
+    await expect(runLoaderSmoke({
+      label: 'failure fixture',
+      tempDirPrefix: 'loader-smoke-fail-',
+      binScript: fixture('fail'),
+      configPath,
+      tsconfigPath,
+    })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed')
+  })
+
+  it('kills a process at its deadline and reports captured output', async () => {
+    await expect(runLoaderSmoke({
+      label: 'hanging fixture',
+      tempDirPrefix: 'loader-smoke-hang-',
+      binScript: fixture('hang'),
+      configPath,
+      tsconfigPath,
+      processTimeoutMs: 100,
+    })).rejects.toThrow('hanging fixture did not exit within 0.1s.')
+  })
+})

+ 11 - 0
packages/support/loader-smoke/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": []
+}

+ 2 - 5
packages/timeout/timeout-policy/src/index.ts

@@ -6,7 +6,6 @@
  */
 
 import type { Context } from 'cordis'
-import type { CallId } from '@deepseek-ai/dsh-llm'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
 
@@ -30,13 +29,11 @@ export const inject = ['tools']
  * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
  * this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
  *
- * @param callId - the timed-out call's id, carried onto the replacement result.
  * @param timeoutMs - the elapsed budget, rendered into the model-facing message.
  * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
  */
-export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
+function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
   return {
-    callId,
     content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
     isError: true,
     error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
@@ -68,7 +65,7 @@ export function apply(ctx: Context): void {
       // quiescence; replace whatever it returned (its own abort result) with the
       // structured TOOL_TIMEOUT the model sees.
       if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
-        return toolTimeoutResult(exec.callId, timeoutMs)
+        return toolTimeoutResult(timeoutMs)
       }
       return result
     } finally {

+ 4 - 14
packages/timeout/timeout-policy/tests/timeout-policy.spec.ts

@@ -11,9 +11,9 @@ import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
+import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
 import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
-import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
+import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
 
 /** Mount the registry + the zero-config timeout-policy enforcer. */
 async function setup() {
@@ -60,7 +60,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
     ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
       async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
     const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
-    expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
+    expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
   })
 
   it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
@@ -109,7 +109,6 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
     await vi.advanceTimersByTimeAsync(150)
     const result = await pending
     expect(result).toEqual({
-      callId: CallId('c1'),
       content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
       isError: true,
       error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
@@ -140,16 +139,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
   })
 })
 
-describe('toolTimeoutResult', () => {
-  it('builds the structured TOOL_TIMEOUT result', () => {
-    expect(toolTimeoutResult(CallId('c9'), 250)).toEqual({
-      callId: CallId('c9'),
-      content: [{ type: 'text', text: 'Error: tool call timed out after 250ms' }],
-      isError: true,
-      error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
-    } satisfies ToolExecutionResult)
-  })
-
+describe('timeout-policy contract', () => {
   it('exposes the owned code constant', () => {
     expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
   })

+ 2 - 1
packages/ui/README.md

@@ -9,13 +9,14 @@ Integrations that expose the agent to an external editor or client. These are **
 | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
 | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
 | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
+| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) |
 | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
 | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
 | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
 | `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) |
 | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
 
-A UI integration is a client-driver plugin, not a loop change or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration.
+A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects.
 
 `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
 

+ 1 - 1
packages/ui/stdio-agent/README.md

@@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
 | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
 | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
 | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
-| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
+| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent |
 
 `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
 

+ 4 - 2
packages/ui/stdio-agent/package.json

@@ -38,9 +38,10 @@
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-agent-core": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
-    "@deepseek-ai/dsh-tools": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
+    "@deepseek-ai/dsh-stdio": "^0.0.1",
     "@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
+    "@deepseek-ai/dsh-tools": "^0.0.1",
     "@deepseek-ai/dsh-user-interaction": "^0.0.1",
     "cordis": "^4.0.0-rc.7",
     "schemastery": "^3.17.0"
@@ -55,9 +56,10 @@
     "@deepseek-ai/dsh-agent-core": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-stdio": "workspace:^",
     "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-interaction": "workspace:^",
     "cordis": "^4.0.0-rc.7",
     "schemastery": "^3.17.0"

+ 4 - 4
packages/ui/stdio-agent/src/index.ts

@@ -1,8 +1,8 @@
 /**
  * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
- * coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the
- * in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent
- * the UI drives.
+ * coupled front-door cluster a terminal chat needs — a console logger, the independently
+ * packaged readline UI, JSONL session persistence, the user-interaction seam with its
+ * `ask_user_question` tool, and a pre-created `main` agent the UI drives.
  * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
  * Loader plugin intentionally exposes named exports only; a default export
  * would hide its `Config` schema (see docs/postmortem/0001).
@@ -19,7 +19,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-core'
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
-import * as uiStdio from './stdio-chat.ts'
+import * as uiStdio from '@deepseek-ai/dsh-stdio'
 
 export const name = 'stdio-agent'
 

+ 1 - 0
packages/ui/stdio-agent/tests/built-bin.e2e.ts

@@ -24,6 +24,7 @@ const dshPackages = [
   'bash/tool-bash', 'support/invariants', 'ui/app-boot',
   'session-persistence/session-persistence',
   'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
+  'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
 ]
 const vendorPackages = [
   'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',

+ 3 - 0
packages/ui/stdio-agent/tsconfig.json

@@ -35,6 +35,9 @@
     {
       "path": "../user-interaction"
     },
+    {
+      "path": "../stdio"
+    },
     {
       "path": "../tool-ask-user"
     },

+ 42 - 0
packages/ui/stdio/README.md

@@ -0,0 +1,42 @@
+# @deepseek-ai/dsh-stdio
+
+The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal.
+
+This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
+
+## Config
+
+| Key | Default | Meaning |
+|---|---|---|
+| `welcome` | `ready.` | Banner printed before the first prompt |
+| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown |
+
+The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects.
+
+```yaml
+- id: stdio
+  name: '@deepseek-ai/dsh-stdio'
+  config:
+    welcome: 'agent REPL ready. Give it a coding task.'
+    agent: main
+```
+
+## Model Experience
+
+### Readline prompt input
+
+**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
+
+**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens.
+
+### Terminal user-interaction answers
+
+**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`.
+
+**Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
+
+## Known Limitations and Deferred Work
+
+- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label.
+- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews.
+- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process.

+ 42 - 0
packages/ui/stdio/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-stdio",
+  "description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-user-interaction": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 29 - 5
packages/ui/stdio-agent/src/stdio-chat.ts → packages/ui/stdio/src/index.ts

@@ -2,7 +2,11 @@
  * The stdio app's readline UI: reads lines from stdin into `agent.send()` or
  * `steer()`, renders the durable event stream to stdout, and exits piped input
  * only after submitted work reaches idle.
- * @module @deepseek-ai/dsh-stdio-agent/stdio-chat
+ *
+ * This package is the independently composable stdio front door. It establishes
+ * the terminal channel and drives an agent created or resumed by app or
+ * developer code.
+ * @module @deepseek-ai/dsh-stdio
  */
 
 import { createInterface } from 'node:readline'
@@ -26,8 +30,6 @@ export const inject = ['agents', 'userInteraction']
 export interface Config {
   /** Banner printed once on start, before the first `> ` prompt. */
   welcome?: string
-  // TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the
-  // precreated `main` agent; remove configurability and its config-only test.
   /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
   agent?: string
 }
@@ -350,16 +352,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   }, 'ui-stdio')
 }
 
+/**
+ * Open the terminal channel once its configured agent exists. Generated stdio
+ * projects boot the Cordis tree first and create or resume the agent from
+ * developer code immediately afterward, so stdin must remain untouched until
+ * the matching `agent/created` notification arrives.
+ * @param ctx - the context supplying the agent registry and event stream.
+ * @param config - presentation and target-agent configuration.
+ * @param runtime - process-I/O seam.
+ */
+export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void {
+  const agentId = AgentId(config.agent ?? 'main')
+  if (ctx.agents.get(agentId) !== undefined) {
+    createStdioChat(ctx, config, runtime)
+    return
+  }
+  const dispose = ctx.on('agent/created', (agent) => {
+    if (agent.id !== agentId) return
+    dispose()
+    createStdioChat(ctx, config, runtime)
+  })
+}
+
 /**
  * Cordis entry point. Binds the real `process` streams and delegates to
- * {@link createStdioChat}; the indirection keeps the side-effecting handles out
+ * {@link mountStdio}; the indirection keeps the side-effecting handles out
  * of the testable core, which is why the unit suite drives `createStdioChat`
  * directly. This thin wrapper is exercised end-to-end by the keyless
  * Loader-path e2e smoke in `examples/echo-agent` (the real product entry).
  */
 /* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */
 export function apply(ctx: Context, config: Config): void {
-  createStdioChat(ctx, config, {
+  mountStdio(ctx, config, {
     input: process.stdin,
     output: process.stdout,
     exit: code => process.exit(code),

+ 19 - 0
packages/ui/stdio/tests/plugin-shape.spec.ts

@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import Loader from '@cordisjs/plugin-loader'
+import * as stdio from '../src/index.ts'
+
+/** Real Loader export-path guard for the namespace stdio plugin. */
+describe('dsh-stdio plugin export shape', () => {
+  it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
+    expect('default' in stdio).toBe(false)
+    expect(typeof stdio.apply).toBe('function')
+
+    const loader = Object.create(Loader.prototype) as Loader
+    const unwrapped = loader.unwrapExports(stdio) as Record<string, unknown>
+    expect(unwrapped).toBe(stdio)
+    expect(unwrapped.name).toBe('ui-stdio')
+    expect(unwrapped.inject).toEqual(['agents', 'userInteraction'])
+    expect(unwrapped.Config).toBeDefined()
+    expect(typeof unwrapped.apply).toBe('function')
+  })
+})

+ 2 - 2
packages/ui/stdio-agent/tests/readline.spec.ts → packages/ui/stdio/tests/readline.spec.ts

@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
 import type { Readable, Writable } from 'node:stream'
 import { describe, expect, it, vi } from 'vitest'
 import type { Context } from 'cordis'
-import type { StdioRuntime } from '../src/stdio-chat.ts'
+import type { StdioRuntime } from '../src/index.ts'
 
 const createInterface = vi.hoisted(() => vi.fn(() => {
   const reader = new EventEmitter() as EventEmitter & { close(): void }
@@ -33,7 +33,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
 
 describe('createStdioChat readline mode', () => {
   it('enables terminal editing only when both stdio streams are TTYs', async () => {
-    const { createStdioChat } = await import('../src/stdio-chat.ts')
+    const { createStdioChat } = await import('../src/index.ts')
 
     const tty = fakeRuntime(true, true)
     createStdioChat(fakeContext(), {}, tty)

+ 50 - 1
packages/ui/stdio-agent/tests/stdio-chat.spec.ts → packages/ui/stdio/tests/stdio.spec.ts

@@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
-import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
+import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts'
 
 /**
  * Unit tests for the stdio UI plugin. They drive the REAL plugin body
@@ -93,6 +93,55 @@ function flushExit(): Promise<void> {
   return new Promise(resolve => setTimeout(resolve, 250))
 }
 
+describe('mountStdio readiness', () => {
+  it('leaves stdin untouched until the configured agent is created', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const { runtime, out } = makeRuntime()
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      mountStdio(inner, CONFIG, runtime)
+    }, { inject: ['agents', 'userInteraction'] }))
+
+    expect(out.text()).toBe('')
+    ctx.agents.register(makeAgent('other'))
+    expect(out.text()).toBe('')
+    ctx.agents.register(makeAgent('main'))
+    expect(out.text()).toBe('hi there\n> ')
+    await fiber.dispose()
+  })
+
+  it('opens immediately when the configured agent already exists', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    ctx.agents.register(makeAgent('main'))
+    const { runtime, out } = makeRuntime()
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      mountStdio(inner, CONFIG, runtime)
+    }, { inject: ['agents', 'userInteraction'] }))
+
+    expect(out.text()).toBe('hi there\n> ')
+    await fiber.dispose()
+  })
+
+  it('waits for main when no target agent is configured', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const { runtime, out } = makeRuntime()
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      mountStdio(inner, { welcome: 'ready' }, runtime)
+    }, { inject: ['agents', 'userInteraction'] }))
+
+    ctx.agents.register(makeAgent('other'))
+    expect(out.text()).toBe('')
+    ctx.agents.register(makeAgent('main'))
+    expect(out.text()).toBe('ready\n> ')
+    await fiber.dispose()
+  })
+})
+
 describe('createStdioChat rendering', () => {
   it('writes the welcome banner and prompt on start', async () => {
     const { out } = await setup()

+ 30 - 0
packages/ui/stdio/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../user-interaction"
+    }
+  ]
+}

+ 1 - 1
packages/web/tool-web/README.md

@@ -32,7 +32,7 @@ Each tool is registered independently; a product that wants only one disables th
 
 Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
 
-The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
+The tool never calls a provider's `available()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
 
 ## Model Experience
 

+ 1 - 1
packages/web/tool-web/src/fetch.ts

@@ -96,7 +96,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
       const input = parseFetchArgs(args)
       const result = await ctx.web.fetch(
         { url: input.url },
-        exec.signal ? { signal: exec.signal } : undefined,
+        exec.signal,
       )
       return [{ type: 'text', text: formatFetchOutput(result) }]
     },

+ 1 - 1
packages/web/tool-web/src/search.ts

@@ -113,7 +113,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
       const input = parseSearchArgs(args)
       const result = await ctx.web.search(
         { query: input.query, maxResults },
-        exec.signal ? { signal: exec.signal } : undefined,
+        exec.signal,
       )
       return [{ type: 'text', text: formatSearchOutput(result) }]
     },

+ 13 - 6
packages/web/tool-web/tests/integration.spec.ts

@@ -134,7 +134,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
     await tctx.plugin(ToolRegistry)
     await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
     // Provider backstop well ABOVE the tool-call budget, so the policy wins.
-    await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
+    await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000 })
     await tctx.plugin(TimeoutPolicy)
     // The tool-call budget is declared by tool-web config, enforced by the policy.
     tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
@@ -156,11 +156,18 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
     expect(text).toContain('timed out after 50ms')
   })
 
-  it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
-    // A direct seam caller does not go through tools/execute, so the tool-call policy never
-    // applies; the provider's own timeout is the only budget. A short request hint must therefore
-    // produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`.
-    const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
+  it('the provider backstop still protects a direct provider call (no tool-call policy in that path)', async () => {
+    // A direct provider caller bypasses tools/execute, so a short configured backstop
+    // must produce provider-owned WEB_FETCH_TIMEOUT rather than TOOL_TIMEOUT.
+    const direct = new WebFetchLocal.LocalFetchProvider({
+      maxUrlLength: 2048,
+      maxResponseBytes: 5_000_000,
+      maxBodyChars: 100_000,
+      timeoutMs: 50,
+      maxRedirects: 5,
+      userAgent: 'integration-test',
+    })
+    const err = await direct.fetch({ url: slowBase }).then(
       () => undefined,
       (e: unknown) => e as { code?: string },
     )

+ 34 - 34
packages/web/tool-web/tests/tool-web.spec.ts

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import WebService from '@deepseek-ai/dsh-web'
-import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
+import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web'
 import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
 import {
   formatSearchOutput,
@@ -18,10 +18,10 @@ import {
   WEB_SEARCH_MAX_RESULTS,
 } from '@deepseek-ai/dsh-tool-web'
 
-const available: WebProviderStatus = { available: true }
+const available = true
 
-function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider {
-  return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) }
+function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
+  return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
 }
 
 /** Mount the real registry, seam, and tool-web; return an executor helper. */
@@ -46,7 +46,7 @@ async function mountTools(opts: {
 describe('search formatting', () => {
   it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
     const out = formatSearchOutput({
-      providerId: 'p', query: 'q', content: 'an answer', truncated: false,
+      content: 'an answer', truncated: false,
       sources: [
         { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
         { url: 'https://b.test/y' },
@@ -59,19 +59,19 @@ describe('search formatting', () => {
   })
 
   it('reports no results when there is neither content nor sources', () => {
-    expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false }))
+    expect(formatSearchOutput({ sources: [], truncated: false }))
       .toContain('No results found.')
   })
 
   it('renders content alone when there are no sources', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false })
+    const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
     expect(out).toContain('just an answer')
     expect(out).not.toContain('No results found.')
     expect(out).not.toContain('Sources:')
   })
 
   it('notes truncation', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true })
+    const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
     expect(out).toContain('Showing the first 1 sources')
   })
 
@@ -88,7 +88,7 @@ describe('search formatting', () => {
 describe('fetch formatting', () => {
   it('renders an html body to markdown text with a status header', () => {
     const out = formatFetchOutput({
-      providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false,
+      url: 'https://a.test', statusCode: 200, truncated: false,
       body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
     })
     expect(out).toContain('Fetched https://a.test (HTTP 200)')
@@ -98,7 +98,7 @@ describe('fetch formatting', () => {
 
   it('passes a text body through and notes truncation', () => {
     const out = formatFetchOutput({
-      providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true,
+      url: 'https://a.test', statusCode: 200, truncated: true,
       body: { kind: 'text', content: 'plain' },
     })
     expect(out).toContain('plain')
@@ -155,7 +155,7 @@ describe('htmlToMarkdown', () => {
   })
 
   it('falls back to the raw URL as a source label when the URL is unparseable', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] })
+    const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
     expect(out).toContain('[not a url](not a url)')
   })
 })
@@ -209,7 +209,7 @@ describe('tool-web registration', () => {
 describe('tool-web execution through the real registry', () => {
   it('executes web_search and formats the result', async () => {
     const result: WebSearchResult = {
-      providerId: 'stub-search', query: 'q', content: 'answer', truncated: false,
+      content: 'answer', truncated: false,
       sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }],
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
@@ -228,8 +228,8 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
-    const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
-    ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) })
+    const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
+    ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
     const out = await call('web_search', { query: 'q' })
     expect(out.isError).toBe(true)
     expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
@@ -237,7 +237,7 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
-    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
+    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
     const out = await call('web_search', { query: 123 })
     expect(out.isError).toBe(true)
     expect(out.error?.code).toBe('INVALID_ARGS')
@@ -249,14 +249,14 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
-    const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {}
+    const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
     const fetchProvider = {
       id: 'stub-fetch',
-      status: () => available,
-      fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => {
+      available: () => available,
+      fetch: (request: { url: string }, signal?: AbortSignal) => {
         seen.request = request
-        seen.signal = exec?.signal
-        return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
+        seen.signal = signal
+        return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
       },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
@@ -271,21 +271,21 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
-    const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {}
+    const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
     const fetchProvider = {
       id: 'stub-fetch',
-      status: () => available,
-      fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => {
-        seen.passedExec = exec !== undefined
-        seen.signal = exec?.signal
-        return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
+      available: () => available,
+      fetch: (request: { url: string }, signal?: AbortSignal) => {
+        seen.passedSignal = signal !== undefined
+        seen.signal = signal
+        return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
       },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
-    // No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`).
+    // No signal on the execution: the tool passes `undefined`.
     const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
     expect(out.isError).toBe(false)
-    expect(seen.passedExec).toBe(false)
+    expect(seen.passedSignal).toBe(false)
     expect(seen.signal).toBeUndefined()
     await fiber.dispose()
   })
@@ -294,8 +294,8 @@ describe('tool-web execution through the real registry', () => {
     const seen: { signal?: AbortSignal | undefined } = {}
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
+      available: () => available,
+      search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
     const controller = new AbortController()
@@ -310,8 +310,8 @@ describe('searchMaxResults is plugin config', () => {
     const seen: { maxResults?: number | undefined } = {}
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
+      available: () => available,
+      search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
     await call('web_search', { query: 'q' })
@@ -323,8 +323,8 @@ describe('searchMaxResults is plugin config', () => {
     const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }),
+      available: () => available,
+      search: () => Promise.resolve({ sources, truncated: false }),
     }
     const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
     const out = await call('web_search', { query: 'q' })

+ 2 - 3
packages/web/web-fetch-local/README.md

@@ -8,7 +8,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
 
-The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
+The provider's `timeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
 
 A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed.
 
@@ -28,8 +28,7 @@ A shipping web-tool deployment sets the provider backstop above the tool budget,
 | `maxUrlLength` | `2048` | Maximum accepted request URL length. |
 | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
 | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
-| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
-| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
+| `timeoutMs` | `30_000` | Fetch timeout within Node's timer range — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
 | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
 | `userAgent` | `deepseek-harness/…` | `User-Agent` header. |
 

+ 12 - 7
packages/web/web-fetch-local/src/index.ts

@@ -13,6 +13,8 @@ import type {} from '@deepseek-ai/dsh-web'
 import { LocalFetchProvider } from './provider.ts'
 import type { LocalFetchLimits } from './provider.ts'
 
+const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647
+
 export {
   LOCAL_FETCH_PROVIDER_ID,
   LocalFetchProvider,
@@ -38,10 +40,8 @@ export interface Config {
   maxResponseBytes?: number
   /** Maximum decoded body length in characters. */
   maxBodyChars?: number
-  /** Default fetch timeout in milliseconds. */
+  /** Default fetch timeout in milliseconds, within Node's timer range. */
   timeoutMs?: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs?: number
   /** Maximum number of same-origin redirect hops to follow. */
   maxRedirects?: number
   /** `User-Agent` header sent on every request. */
@@ -53,7 +53,6 @@ export const Config: z<Config> = z.object({
   maxResponseBytes: z.number().default(5_000_000),
   maxBodyChars: z.number().default(100_000),
   timeoutMs: z.number().default(30_000),
-  maxTimeoutMs: z.number().default(120_000),
   maxRedirects: z.number().default(5),
   userAgent: z.string().default(DEFAULT_USER_AGENT),
 })
@@ -68,6 +67,14 @@ function assertPositiveFinite(name: string, value: number): void {
   }
 }
 
+/** Node coerces larger timer delays to 1 ms, so reject them at configuration time. */
+function assertTimeoutMs(value: number): void {
+  assertPositiveFinite('timeoutMs', value)
+  if (value > MAX_NODE_TIMER_DELAY_MS) {
+    throw new Error(`web-fetch-local: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`)
+  }
+}
+
 /** The redirect hop cap must be a non-negative integer (0 follows no redirects). */
 function assertNonNegativeInteger(name: string, value: number): void {
   if (!Number.isInteger(value) || value < 0) {
@@ -82,15 +89,13 @@ export function apply(ctx: Context, config: Config): void {
   assertPositiveFinite('maxUrlLength', resolved.maxUrlLength)
   assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes)
   assertPositiveFinite('maxBodyChars', resolved.maxBodyChars)
-  assertPositiveFinite('timeoutMs', resolved.timeoutMs)
-  assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
+  assertTimeoutMs(resolved.timeoutMs)
   assertNonNegativeInteger('maxRedirects', resolved.maxRedirects)
   const limits: LocalFetchLimits = {
     maxUrlLength: resolved.maxUrlLength,
     maxResponseBytes: resolved.maxResponseBytes,
     maxBodyChars: resolved.maxBodyChars,
     timeoutMs: resolved.timeoutMs,
-    maxTimeoutMs: resolved.maxTimeoutMs,
     maxRedirects: resolved.maxRedirects,
     userAgent: resolved.userAgent,
   }

+ 7 - 11
packages/web/web-fetch-local/src/provider.ts

@@ -9,8 +9,8 @@
  */
 
 import { WebError } from '@deepseek-ai/dsh-web'
-import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
-import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
+import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web'
+import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
 
 /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
@@ -23,8 +23,6 @@ export interface LocalFetchLimits {
   maxBodyChars: number
   /** Default fetch timeout in milliseconds. */
   timeoutMs: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs: number
   /** Maximum number of (same-origin) redirect hops to follow. */
   maxRedirects: number
   /** `User-Agent` header sent on every request. */
@@ -41,17 +39,16 @@ export class LocalFetchProvider implements WebFetchProvider {
   constructor(private readonly limits: LocalFetchLimits) {}
 
   /** No credentials to check — an anonymous public fetcher is always usable. */
-  status(): WebProviderStatus {
-    return { available: true }
+  available(): boolean {
+    return true
   }
 
-  async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> {
-    if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
-    const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
+  async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
+    if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
 
     // One signal stops both the request and body read. The deadline's TimeoutReason later
     // distinguishes this provider's timeout from caller or outer-deadline cancellation.
-    using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
+    using d = deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT')
     return await this.followAndRead(request.url, d.signal)
   }
 
@@ -142,7 +139,6 @@ export class LocalFetchProvider implements WebFetchProvider {
     const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content }
 
     return {
-      providerId: this.id,
       url: finalUrl.toString(),
       statusCode: response.status,
       body,

+ 12 - 11
packages/web/web-fetch-local/tests/fetch-local.spec.ts

@@ -12,7 +12,6 @@ const limits: LocalFetchLimits = {
   maxResponseBytes: 5_000_000,
   maxBodyChars: 100_000,
   timeoutMs: 5_000,
-  maxTimeoutMs: 10_000,
   maxRedirects: 5,
   userAgent: 'test-agent/1.0',
 }
@@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => {
   it('fetches a text body', async () => {
     handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
     const result = await provider().fetch({ url: base })
-    expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID)
+    expect(provider().available()).toBe(true)
     expect(result.statusCode).toBe(200)
     expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
     expect(result.truncated).toBe(false)
@@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
   it('honors a pre-aborted signal', async () => {
     const controller = new AbortController()
     controller.abort()
-    await expect(provider().fetch({ url: base }, { signal: controller.signal }))
+    await expect(provider().fetch({ url: base }, controller.signal))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
   })
 
   it('aborts an in-flight fetch via the signal', async () => {
     handler = (_req, _res) => { /* never responds */ }
     const controller = new AbortController()
-    const promise = provider().fetch({ url: base }, { signal: controller.signal })
+    const promise = provider().fetch({ url: base }, controller.signal)
     controller.abort()
     await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
   })
@@ -325,11 +324,6 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 
-  it('caps the per-request timeout at maxTimeoutMs', async () => {
-    handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
-    const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 })
-    expect(result.statusCode).toBe(200)
-  })
 })
 
 describe('LocalFetchProvider body cancellation on error paths', () => {
@@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => {
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
     const fiber = await ctx.plugin(fetchPlugin, {})
     await expect(ctx.web.fetch({ url: `${base}/` }))
-      .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
+      .resolves.toMatchObject({ statusCode: 200 })
     await fiber.dispose()
     await expect(ctx.web.fetch({ url: `${base}/` }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -402,6 +396,13 @@ describe('web-fetch-local plugin registration', () => {
       .rejects.toThrow(/timeoutMs must be a positive finite number/)
   })
 
+  it('rejects a timeout beyond Node timer range at construction', async () => {
+    const ctx = new Context()
+    await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
+    await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 }))
+      .rejects.toThrow(/timeoutMs must be no greater than 2147483647/)
+  })
+
   it('rejects a fractional redirect cap at construction', async () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
@@ -421,7 +422,7 @@ describe('web-fetch-local plugin registration', () => {
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
     const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
     await expect(ctx.web.fetch({ url: `${base}/` }))
-      .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
+      .resolves.toMatchObject({ statusCode: 200 })
     await fiber.dispose()
   })
 })

+ 2 - 2
packages/web/web-search-deepseek/README.md

@@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
-| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
+| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
 | `model` | `deepseek-v4-flash` | Anthropic-format model name. |
 | `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
 | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |

+ 11 - 13
packages/web/web-search-deepseek/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -50,7 +49,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface DeepSeekSearchProviderOptions {
-  /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** DeepSeek API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/messages` is appended. */
   baseURL: string
@@ -93,12 +92,11 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
  * the same URL across searches). The seam owns the final `maxResults` truncation, so
  * `truncated` is always `false` here.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed Messages response body.
  * @returns the normalized result with deduped, snippet-joined sources.
  * @throws {@link WebError} when native search produced no result block.
  */
-export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
+export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResult {
   const blocks = response.content ?? []
   const resultBlocks = blocks.filter(
     (block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result',
@@ -126,7 +124,7 @@ export function mapAnthropicResponse(query: string, response: AnthropicResponse)
       })
     }
   }
-  return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false }
+  return { sources, truncated: false }
 }
 
 /** The DeepSeek-backed search provider. */
@@ -135,14 +133,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
 
   constructor(private readonly options: DeepSeekSearchProviderOptions) {}
 
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && URL.canParse(this.options.baseURL)
+      && isPositiveInteger(this.options.maxTokens)
+      && isPositiveInteger(this.options.maxUses)
   }
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     let response: Response
     try {
       response = await fetch(`${this.options.baseURL}/messages`, {
@@ -166,7 +164,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
           }],
           tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
@@ -194,7 +192,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as AnthropicResponse
-      return mapAnthropicResponse(request.query, payload)
+      return mapAnthropicResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
       if (error instanceof WebError) throw error

+ 0 - 1
packages/web/web-search-deepseek/tests/deepseek.e2e.ts

@@ -29,7 +29,6 @@ maybe('DeepSeekSearchProvider real API', () => {
       maxUses: DEEPSEEK_DEFAULT_MAX_USES,
     })
     const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
-    expect(result.providerId).toBe('deepseek')
     expect(result.sources.length).toBeGreaterThan(0)
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 60_000)

+ 18 - 25
packages/web/web-search-deepseek/tests/deepseek.spec.ts

@@ -64,10 +64,8 @@ describe('citationSnippets', () => {
 
 describe('mapAnthropicResponse', () => {
   it('joins result items to citation snippets and maps page_age to publishedAt', () => {
-    const result = mapAnthropicResponse('q', searchResponse())
+    const result = mapAnthropicResponse(searchResponse())
     expect(result).toEqual({
-      providerId: DEEPSEEK_PROVIDER_ID,
-      query: 'q',
       sources: [
         { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' },
         { url: 'https://b.test', title: 'B' },
@@ -77,7 +75,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('dedupes repeated urls across result blocks (first wins)', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] },
@@ -87,7 +85,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('skips non-result items and items with an empty url', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [{
         type: 'web_search_tool_result',
         content: [
@@ -101,14 +99,14 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('omits optional fields when absent or empty', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }],
     })
     expect(result.sources).toEqual([{ url: 'https://a.test' }])
   })
 
   it('tolerates a text block with no citations', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'text', text: 'no citations here' },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] },
@@ -118,7 +116,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('tolerates a result block with no content array', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'web_search_tool_result' },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] },
@@ -128,38 +126,33 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => {
-    expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] }))
+    expect(() => mapAnthropicResponse({ content: [{ type: 'text', text: 'just prose, no search' }] }))
       .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 
   it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => {
-    expect(() => mapAnthropicResponse('q', {}))
+    expect(() => mapAnthropicResponse({}))
       .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 })
 
-describe('DeepSeekSearchProvider status', () => {
+describe('DeepSeekSearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true })
+    expect(new DeepSeekSearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when request limits are not positive integers', () => {
-    expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
+    expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false)
+    expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false)
   })
 })
 
@@ -186,7 +179,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -268,7 +261,7 @@ describe('web-search-deepseek plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
     const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -318,7 +311,7 @@ describe('web-search-deepseek plugin registration', () => {
     const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
     // A collapsed export shape (dropped inject) would throw "without inject" here.
     const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
     await fiber.dispose()
   })
 

+ 2 - 2
packages/web/web-search-exa/README.md

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). |
-| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent makes the provider unavailable. |
+| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable. |
 | `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. |
 | `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. |
 | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. |

+ 11 - 14
packages/web/web-search-exa/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -33,7 +32,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface ExaSearchProviderOptions {
-  /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** Exa API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/search` is appended. */
   baseURL: string
@@ -68,18 +67,17 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
 /**
  * Map an Exa response envelope to a normalized search result.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed `POST /search` response body.
  * @returns the normalized result; snippet-less entries are dropped
  *   ({@link mapExaResult}).
  */
-export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult {
+export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
   const sources = (response.results ?? [])
     .map(mapExaResult)
     .filter((source): source is WebSearchSource => source !== undefined)
   // Exa returns no generated answer, so `content` is omitted. The seam owns the
   // final `maxResults` truncation, so this provider reports `truncated: false`.
-  return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false }
+  return { sources, truncated: false }
 }
 
 /** The Exa-backed search provider. */
@@ -88,15 +86,14 @@ export class ExaSearchProvider implements WebSearchProvider {
 
   constructor(private readonly options: ExaSearchProviderOptions) {}
 
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' }
-    if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && isValidBaseUrl(this.options.baseURL)
+      && isPositiveInteger(this.options.highlightsPerResult)
+      && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults))
   }
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     // A per-request bound wins over the configured default; either may be absent.
     const numResults = request.maxResults ?? this.options.numResults
     let response: Response
@@ -115,7 +112,7 @@ export class ExaSearchProvider implements WebSearchProvider {
           contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
           ...numResults !== undefined ? { numResults } : {},
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
@@ -143,7 +140,7 @@ export class ExaSearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as ExaSearchResponse
-      return mapExaResponse(request.query, payload)
+      return mapExaResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
       throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

+ 0 - 1
packages/web/web-search-exa/tests/exa.e2e.ts

@@ -17,7 +17,6 @@ maybe('ExaSearchProvider real API', () => {
       highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
     })
     const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 })
-    expect(result.providerId).toBe('exa')
     expect(result.sources.length).toBeGreaterThan(0)
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 30_000)

+ 11 - 18
packages/web/web-search-exa/tests/exa.spec.ts

@@ -38,7 +38,7 @@ describe('Exa result mapping', () => {
   })
 
   it('maps a response to a result with no content and filtered sources', () => {
-    const result = mapExaResponse('q', {
+    const result = mapExaResponse({
       results: [
         { url: 'https://a.test', highlights: ['one'] },
         { url: 'https://b.test' },
@@ -46,8 +46,6 @@ describe('Exa result mapping', () => {
       ],
     })
     expect(result).toEqual({
-      providerId: EXA_PROVIDER_ID,
-      query: 'q',
       sources: [
         { url: 'https://a.test', snippet: 'one' },
         { url: 'https://c.test', title: 'C', snippet: 'three' },
@@ -58,36 +56,31 @@ describe('Exa result mapping', () => {
   })
 
   it('tolerates a missing results array', () => {
-    expect(mapExaResponse('q', {}).sources).toEqual([])
+    expect(mapExaResponse({}).sources).toEqual([])
   })
 
 })
 
-describe('ExaSearchProvider status', () => {
+describe('ExaSearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new ExaSearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new ExaSearchProvider(options).status()).toEqual({ available: true })
+    expect(new ExaSearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when highlightsPerResult is not a positive integer', () => {
-    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false)
+    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false)
   })
 
   it('is misconfigured when numResults is set but not a positive integer', () => {
-    expect(new ExaSearchProvider({ ...options, numResults: -1 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false)
   })
 })
 
@@ -139,7 +132,7 @@ describe('ExaSearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -209,7 +202,7 @@ describe('web-search-exa plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
     const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

+ 2 - 2
packages/web/web-search-perplexity/README.md

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. |
-| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent makes the provider unavailable. |
+| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable. |
 | `model` | `sonar` | Search model name. |
 | `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. |
 | `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. |

+ 9 - 14
packages/web/web-search-perplexity/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -36,7 +35,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface PerplexitySearchProviderOptions {
-  /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** Perplexity API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/chat/completions` is appended. */
   baseURL: string
@@ -68,18 +67,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
  * structured `search_results[]`; falls back to URL-only `citations[]` (those
  * sources carry just a `url`) only when `search_results` is absent.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed chat-completions response body.
  * @returns the normalized result; `content` is omitted when the answer is empty.
  */
-export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
+export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult {
   const content = response.choices?.[0]?.message?.content
   const sources: WebSearchSource[] = response.search_results !== undefined
     ? response.search_results.map(mapPerplexityResult)
     : (response.citations ?? []).map(url => ({ url }))
   return {
-    providerId: PERPLEXITY_PROVIDER_ID,
-    query,
     ...content != null && content.length > 0 ? { content } : {},
     sources,
     truncated: false,
@@ -95,15 +91,14 @@ export class PerplexitySearchProvider implements WebSearchProvider {
   // Availability checks stay beside each provider's distinct config contract;
   // a shared base class would obscure which fields make this backend usable.
   /* jscpd:ignore-start */
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && URL.canParse(this.options.baseURL)
+      && isPositiveInteger(this.options.maxTokens)
   }
   /* jscpd:ignore-end */
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     let response: Response
     try {
       response = await fetch(`${this.options.baseURL}/chat/completions`, {
@@ -120,7 +115,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
           messages: [{ role: 'user', content: request.query }],
           ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {},
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
@@ -148,7 +143,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as PerplexityResponse
-      return mapPerplexityResponse(request.query, payload)
+      return mapPerplexityResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
       throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

+ 0 - 1
packages/web/web-search-perplexity/tests/perplexity.e2e.ts

@@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => {
       maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS,
     })
     const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
-    expect(result.providerId).toBe('perplexity')
     expect(result.content ?? '').not.toBe('')
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 30_000)

+ 15 - 21
packages/web/web-search-perplexity/tests/perplexity.spec.ts

@@ -20,7 +20,7 @@ afterEach(() => {
 
 describe('Perplexity response mapping', () => {
   it('maps the answer and prefers structured search_results', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       choices: [{ message: { content: 'the answer' } }],
       search_results: [
         { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' },
@@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => {
       citations: ['https://ignored.test'],
     })
     expect(result).toEqual({
-      providerId: PERPLEXITY_PROVIDER_ID,
-      query: 'q',
       content: 'the answer',
       sources: [
         { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' },
@@ -41,7 +39,7 @@ describe('Perplexity response mapping', () => {
   })
 
   it('falls back to URL-only citations when search_results is absent', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       choices: [{ message: { content: 'answer' } }],
       citations: ['https://a.test', 'https://b.test'],
     })
@@ -49,43 +47,39 @@ describe('Perplexity response mapping', () => {
   })
 
   it('omits content when the answer is empty or missing', () => {
-    expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined()
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined()
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined()
   })
 
   it('omits null/empty optional source fields', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }],
     })
     expect(result.sources).toEqual([{ url: 'https://a.test' }])
   })
 
   it('yields no sources when neither search_results nor citations are present', () => {
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
+    expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
   })
 })
 
-describe('PerplexitySearchProvider status', () => {
+describe('PerplexitySearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true })
+    expect(new PerplexitySearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when maxTokens is not a positive integer', () => {
-    expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
+    expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false)
   })
 })
 
@@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse({ citations: [] }))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
     const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

+ 5 - 5
packages/web/web/README.md

@@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del
 | Member | Semantics |
 |---|---|
 | `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
-| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
-| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
+| `search(request, signal?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
+| `fetch(request, signal?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
 
 Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
 
@@ -30,18 +30,18 @@ Selection never depends on registration, config, or HMR order. A capability has
 
 | Situation | Execution |
 |---|---|
-| configured id registered and `status().available` | runs that provider |
+| configured id registered and `available()` | runs that provider |
 | configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
 | configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
 | no id, exactly one registered usable provider | runs it |
 | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
 | no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
 
-The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
+The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `available()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls it — the tool executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
 
 ## Vocabulary
 
-`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
+`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`) → `WebFetchResult` (final `url`, `statusCode`, `body`, `truncated`); cancellation is a direct optional `AbortSignal` argument to `search()`/`fetch()`. `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
 
 ## Model Experience
 

+ 10 - 14
packages/web/web/src/index.ts

@@ -9,11 +9,9 @@
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
 import type {
-  WebExecContext,
   WebFetchProvider,
   WebFetchRequest,
   WebFetchResult,
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -24,12 +22,10 @@ export {
   WebError,
 } from './types.ts'
 export type {
-  WebExecContext,
   WebFetchBody,
   WebFetchProvider,
   WebFetchRequest,
   WebFetchResult,
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -67,7 +63,7 @@ export interface WebServiceConfig {
  * The web access service. Registered as `ctx.web` (one instance per context).
  *
  * Selection semantics (resolved at execution time, never order-dependent):
- * - A configured id that is registered and `status().available` → that provider.
+ * - A configured id that is registered and `available()` → that provider.
  * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
  * - A configured id registered but unavailable →
  *   `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
@@ -138,15 +134,15 @@ export class WebService extends Service {
    * capability cannot run. The seam enforces `request.maxResults` on the result:
    * if the provider over-returns, `sources[]` is truncated and `truncated` set.
    * @param request - the query plus result-shaping options.
-   * @param exec - the tool-execution context, forwarded to the provider.
+   * @param signal - optional cancellation signal forwarded to the provider.
    * @returns the provider's results, capped to `request.maxResults`.
    */
-  async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     const provider = resolveProvider({
       providers: this.searchProviders,
       ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
     })
-    const result = await provider.search(request, exec)
+    const result = await provider.search(request, signal)
     return capSources(result, request.maxResults)
   }
 
@@ -155,21 +151,21 @@ export class WebService extends Service {
    * call time with the selection rules above; throws {@link WebError} when the
    * capability cannot run. A non-2xx response is a result, not a throw.
    * @param request - the URL plus retrieval options.
-   * @param exec - the tool-execution context, forwarded to the provider.
+   * @param signal - optional cancellation signal forwarded to the provider.
    * @returns the retrieval outcome; non-2xx responses resolve descriptively.
    */
-  async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> {
+  async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
     const provider = resolveProvider({
       providers: this.fetchProviders,
       ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
     })
-    return provider.fetch(request, exec)
+    return provider.fetch(request, signal)
   }
 }
 
 interface ResolvableProvider {
   readonly id: string
-  status(): WebProviderStatus
+  available(): boolean
 }
 
 /** Resolve the selected provider or throw the matching {@link WebError}. */
@@ -180,12 +176,12 @@ function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>):
     if (!provider) {
       throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING')
     }
-    if (!provider.status().available) {
+    if (!provider.available()) {
       throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
     }
     return provider
   }
-  const usable = [...providers.values()].filter(provider => provider.status().available)
+  const usable = [...providers.values()].filter(provider => provider.available())
   const [single] = usable
   if (single === undefined) {
     throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')

+ 10 - 42
packages/web/web/src/types.ts

@@ -7,19 +7,6 @@
 
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 
-/**
- * Execution control threaded from the tool layer through the seam into a
- * provider's network requests, stream readers, and expensive decoding. It is
- * NOT business input: the first version carries only `signal` so `tool-web` can
- * propagate turn cancellation, tool timeout, and agent disposal. It deliberately
- * does NOT carry `ToolExecution`, which would make `dsh-web` depend on
- * `dsh-tools`.
- */
-export interface WebExecContext {
-  /** Abort signal a provider must honor for its network/decoding work. */
-  readonly signal?: AbortSignal
-}
-
 /**
  * What one search-capable backend can return. The model-facing argument is just
  * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
@@ -44,10 +31,6 @@ export interface WebSearchRequest {
  * when it cut `sources[]` down to `maxResults`.
  */
 export interface WebSearchResult {
-  /** Id of the provider that produced this result. */
-  readonly providerId: string
-  /** Echo of the query the provider answered. */
-  readonly query: string
   /** Optional provider-generated answer text, search context, or summary. */
   readonly content?: string
   /** Citeable sources, already truncated to the request's `maxResults`. */
@@ -71,14 +54,13 @@ export interface WebSearchSource {
 }
 
 /**
- * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an
- * optional positive hint the provider caps. The request deliberately omits
- * `format`, `prompt`, and extraction controls — those are presentation or
- * higher-level LLM concerns, not safe-retrieval inputs.
+ * What one fetch-capable backend is asked to retrieve. The request deliberately
+ * omits timeout, format, prompt, and extraction controls: cancellation is a
+ * direct execution argument, while presentation and higher-level LLM concerns
+ * belong outside safe retrieval.
  */
 export interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 
 /**
@@ -88,8 +70,6 @@ export interface WebFetchRequest {
  * represent the resource.
  */
 export interface WebFetchResult {
-  /** Id of the provider that produced this result. */
-  readonly providerId: string
   /** The final URL after allowed redirects (the request URL is in the request). */
   readonly url: string
   /** HTTP status code of the fetched response. */
@@ -113,18 +93,6 @@ export type WebFetchBody =
   | { readonly kind: 'html'; readonly content: string }
   | { readonly kind: 'text'; readonly content: string }
 
-/**
- * Whether one concrete provider implementation is usable, by cheap local checks
- * only (credential presence, parseable endpoint config). A provider `status()`
- * must NOT make network calls. It is an input to execution-time selection, not
- * a health system: `WebService.search()`/`fetch()` read it to pick a usable
- * provider, and selection failure surfaces as the structured {@link WebError}
- * codes callers route on.
- */
-export type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-
 /**
  * A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
  * `id` is a stable string, unique within the search capability kind.
@@ -132,9 +100,9 @@ export type WebProviderStatus =
 export interface WebSearchProvider {
   readonly id: string
   /** Cheap local usability check; must not make network calls. */
-  status(): WebProviderStatus
-  /** Run one search; honor `exec.signal` for cancellation. */
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
+  available(): boolean
+  /** Run one search; honor `signal` for cancellation. */
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
 }
 
 /**
@@ -144,9 +112,9 @@ export interface WebSearchProvider {
 export interface WebFetchProvider {
   readonly id: string
   /** Cheap local usability check; must not make network calls. */
-  status(): WebProviderStatus
-  /** Retrieve one URL; honor `exec.signal` for cancellation. */
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+  available(): boolean
+  /** Retrieve one URL; honor `signal` for cancellation. */
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 
 /**

+ 20 - 22
packages/web/web/tests/web.spec.ts

@@ -4,7 +4,6 @@ import WebService, {
   WebError,
   type WebFetchProvider,
   type WebFetchResult,
-  type WebProviderStatus,
   type WebSearchProvider,
   type WebSearchRequest,
   type WebSearchResult,
@@ -13,25 +12,25 @@ import WebService, {
 /** A scripted search provider for contract tests. */
 function makeSearchProvider(
   id: string,
-  status: WebProviderStatus,
+  available: boolean,
   search: (request: WebSearchRequest) => Promise<WebSearchResult>,
 ): WebSearchProvider {
-  return { id, status: () => status, search: request => search(request) }
+  return { id, available: () => available, search: request => search(request) }
 }
 
-function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider {
-  return { id, status: () => status, fetch: () => Promise.resolve(result) }
+function makeFetchProvider(id: string, available: boolean, result: WebFetchResult): WebFetchProvider {
+  return { id, available: () => available, fetch: () => Promise.resolve(result) }
 }
 
-const available: WebProviderStatus = { available: true }
-const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' }
+const available = true
+const unavailable = false
 
-function searchResult(providerId: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
-  return { providerId, query: 'q', sources: [], truncated: false, ...overrides }
+function searchResult(marker: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
+  return { content: marker, sources: [], truncated: false, ...overrides }
 }
 
-function fetchResult(providerId: string): WebFetchResult {
-  return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false }
+function fetchResult(marker: string): WebFetchResult {
+  return { url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: marker }, truncated: false }
 }
 
 /** Mount a WebService on a fresh root context with the given config. */
@@ -46,7 +45,7 @@ describe('WebService registration', () => {
     const { web } = await mountWeb()
 
     const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
 
     dispose()
     await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
@@ -70,7 +69,7 @@ describe('WebService registration', () => {
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     }, { inject: ['web'] }))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
     await fiber.dispose()
     await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
   })
@@ -111,26 +110,26 @@ describe('WebService execution resolution', () => {
     const { web } = await mountWeb({ searchProvider: 'perplexity' })
     web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
   })
 
   it('ignores unusable providers when auto-selecting', async () => {
     const { web } = await mountWeb()
     web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
   })
 
   it('does not let registration order change auto-selection', async () => {
     const a = await mountWeb()
     a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
     a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
 
     const b = await mountWeb()
     b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
     b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
-    await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
   })
 
   it('runs the selected provider and returns its result', async () => {
@@ -139,7 +138,6 @@ describe('WebService execution resolution', () => {
       searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }),
     )))
     const result = await web.search({ query: 'q' })
-    expect(result.providerId).toBe('exa')
     expect(result.content).toBe('answer')
     expect(result.sources).toEqual([{ url: 'https://a' }])
   })
@@ -149,11 +147,11 @@ describe('WebService execution resolution', () => {
     const seen: (AbortSignal | undefined)[] = []
     web.registerSearchProvider({
       id: 'exa',
-      status: () => available,
-      search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) },
+      available: () => available,
+      search: (_request, signal) => { seen.push(signal); return Promise.resolve(searchResult('exa')) },
     })
     const controller = new AbortController()
-    await web.search({ query: 'q' }, { signal: controller.signal })
+    await web.search({ query: 'q' }, controller.signal)
     expect(seen[0]).toBe(controller.signal)
   })
 })
@@ -195,7 +193,7 @@ describe('WebService fetch capability', () => {
     const { web } = await mountWeb()
     web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http')))
     const result = await web.fetch({ url: 'https://example.com' })
-    expect(result.providerId).toBe('local-http')
+    expect(result.body.content).toBe('local-http')
     expect(result.statusCode).toBe(200)
   })
 

+ 38 - 0
pnpm-lock.yaml

@@ -1164,6 +1164,16 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/support/loader-smoke:
+    dependencies:
+      tsx:
+        specifier: ^4.22.4
+        version: 4.22.4
+    devDependencies:
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/support/subagent-mock:
     dependencies:
       schemastery:
@@ -1421,6 +1431,31 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/ui/stdio:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../user-interaction
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
   packages/ui/stdio-agent:
     devDependencies:
       '@cordisjs/plugin-include':
@@ -1450,6 +1485,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-stdio':
+        specifier: workspace:^
+        version: link:../stdio
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt

+ 0 - 1
scripts/type-equiv.manifest.json

@@ -146,7 +146,6 @@
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
-    { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
 
     { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
     { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -56,6 +56,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
   'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
   'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
+  'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
   'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
   'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
   'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' },

+ 2 - 0
tsconfig.build.json

@@ -62,9 +62,11 @@
     { "path": "./packages/ui/app-boot" },
     { "path": "./packages/ui/jsonrpc" },
     { "path": "./packages/ui/jsonrpc-agent" },
+    { "path": "./packages/ui/stdio" },
     { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/support/acp-snapshot" },
+    { "path": "./packages/support/loader-smoke" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
     { "path": "./packages/subagent/tool-subagent" },

+ 2 - 0
tsconfig.json

@@ -73,9 +73,11 @@
     { "path": "./packages/ui/app-boot" },
     { "path": "./packages/ui/jsonrpc" },
     { "path": "./packages/ui/jsonrpc-agent" },
+    { "path": "./packages/ui/stdio" },
     { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/support/acp-snapshot" },
+    { "path": "./packages/support/loader-smoke" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
     { "path": "./packages/subagent/tool-subagent" },