Преглед изворни кода

feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.

- Extract the `Branded<B>` primitive into a new standalone type-only package
  `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
  dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
  dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
  dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
  generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
  the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
  the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
  SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
  that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
  agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
  at the config boundary and the inner create()/resume casts disappear (only the
  genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
  Map keys and public params/exports (SessionStore, AgentRegistry + factory
  options, the ACP session-id surface + ToolPresenter CallId map, the
  persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
  the Branded type-equiv at dsh-brand, fix stale param types in the session/
  agent/bash READMEs, regenerate the cordis catalog + module graph.

Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
Tianyi Cui пре 3 месеци
родитељ
комит
d6a2ab30c8
75 измењених фајлова са 644 додато и 445 уклоњено
  1. 2 1
      docs/cookbook/extension-cookbook.md
  2. 25 25
      docs/cordis-catalog/events-and-services.md
  3. 5 3
      docs/core-data-structures/bash.md
  4. 4 2
      docs/core-data-structures/core.md
  5. 9 4
      docs/module-graph.md
  6. 1 1
      docs/rfc/README.md
  7. 3 3
      docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
  8. 1 1
      docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
  9. 2 1
      examples/coding-agent/tests/coding-task.e2e.ts
  10. 2 1
      examples/coding-agent/tests/full-loop.e2e.ts
  11. 5 3
      examples/coding-agent/tests/resume.e2e.ts
  12. 4 0
      knip.json
  13. 9 9
      packages/bash/bash-local/src/index.ts
  14. 3 3
      packages/bash/bash-local/tests/executor.spec.ts
  15. 3 0
      packages/bash/bash-local/tsconfig.json
  16. 1 1
      packages/bash/bash/README.md
  17. 2 0
      packages/bash/bash/package.json
  18. 6 5
      packages/bash/bash/src/index.ts
  19. 28 3
      packages/bash/bash/src/types.ts
  20. 8 8
      packages/bash/bash/tests/service.spec.ts
  21. 3 0
      packages/bash/bash/tsconfig.json
  22. 7 5
      packages/bash/tool-bash/src/index.ts
  23. 6 5
      packages/bash/tool-bash/tests/integration.spec.ts
  24. 22 22
      packages/bash/tool-bash/tests/tools.spec.ts
  25. 21 13
      packages/core/agent-loop/src/index.ts
  26. 23 23
      packages/core/agent-loop/tests/agent.spec.ts
  27. 13 13
      packages/core/agent-loop/tests/cancel.spec.ts
  28. 11 11
      packages/core/agent-loop/tests/config-session-id.spec.ts
  29. 11 11
      packages/core/agent-loop/tests/coverage-edges.spec.ts
  30. 30 30
      packages/core/agent-loop/tests/loop.spec.ts
  31. 4 4
      packages/core/agent-loop/tests/properties.spec.ts
  32. 16 16
      packages/core/agent-loop/tests/resume.spec.ts
  33. 33 33
      packages/core/agent-loop/tests/review-fixes.spec.ts
  34. 1 1
      packages/core/agent/README.md
  35. 2 0
      packages/core/agent/package.json
  36. 7 7
      packages/core/agent/src/index.ts
  37. 2 1
      packages/core/agent/src/types.ts
  38. 12 12
      packages/core/agent/tests/agent.spec.ts
  39. 3 0
      packages/core/agent/tsconfig.json
  40. 2 2
      packages/core/session/README.md
  41. 2 0
      packages/core/session/package.json
  42. 4 4
      packages/core/session/src/index.ts
  43. 2 1
      packages/core/session/src/types.ts
  44. 21 21
      packages/core/session/tests/session.spec.ts
  45. 3 0
      packages/core/session/tsconfig.json
  46. 4 3
      packages/llm/llm-pi-ai/src/adapter.ts
  47. 1 1
      packages/llm/llm-pi-ai/src/convert.ts
  48. 2 0
      packages/llm/llm/package.json
  49. 6 15
      packages/llm/llm/src/brand.ts
  50. 3 0
      packages/llm/llm/tsconfig.json
  51. 11 11
      packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
  52. 3 3
      packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
  53. 2 2
      packages/session-persistence/session-persistence/src/coordinator.ts
  54. 21 21
      packages/session-persistence/session-persistence/tests/coordinator-contract.ts
  55. 2 1
      packages/support/invariants/src/index.ts
  56. 3 3
      packages/support/invariants/tests/invariants.spec.ts
  57. 2 2
      packages/support/ui-stdio/src/index.ts
  58. 32 26
      packages/ui/acp/src/index.ts
  59. 5 4
      packages/ui/acp/tests/bridge.spec.ts
  60. 28 27
      packages/ui/acp/tests/dispose.spec.ts
  61. 3 1
      packages/ui/acp/tests/edges.spec.ts
  62. 5 4
      packages/ui/acp/tests/load.spec.ts
  63. 3 2
      packages/ui/acp/tests/multi-session.spec.ts
  64. 2 2
      packages/ui/acp/tests/properties.spec.ts
  65. 5 5
      packages/ui/acp/tests/stream-update.spec.ts
  66. 3 2
      packages/ui/acp/tests/turns.spec.ts
  67. 26 0
      packages/util/brand/README.md
  68. 28 0
      packages/util/brand/package.json
  69. 27 0
      packages/util/brand/src/index.ts
  70. 11 0
      packages/util/brand/tsconfig.json
  71. 18 0
      pnpm-lock.yaml
  72. 1 1
      scripts/type-equiv.manifest.json
  73. 1 0
      tsconfig.base.json
  74. 1 0
      tsconfig.build.json
  75. 1 0
      tsconfig.typecheck.json

+ 2 - 1
docs/cookbook/extension-cookbook.md

@@ -38,6 +38,7 @@ A UI plugin consumes `agent/stream-chunk` and session events for rendering, and
 
 ```ts
 import type { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 
 declare function render(text: string): void
 declare function onUserInput(handler: (text: string) => void): void
@@ -49,7 +50,7 @@ export function apply(ctx: Context) {
   ctx.on('agent/stream-chunk', (agent, turn, step, chunk) => {
     if (chunk.type === 'text-delta') render(chunk.text)
   })
-  onUserInput(text => ctx.agents.get('main')?.send([{ type: 'text', text }]))
+  onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
 }
 ```
 

+ 25 - 25
docs/cordis-catalog/events-and-services.md

@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:140`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts)
 
 #### `agent/disposed` — emit
 
@@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:146`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts)
 
 #### `agent/error` — emit
 
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
 
 #### `agent/queued` — emit
 
@@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts)
 
 #### `agent/request` — waterfall
 
@@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts)
 
 #### `agent/status` — emit
 
@@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts)
 
 #### `agent/steering` — emit
 
@@ -97,7 +97,7 @@ Steering content was injected into a running turn.
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-end` — emit
 
@@ -109,7 +109,7 @@ A step ended.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:183`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-result` — waterfall
 
@@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:198`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-start` — emit
 
@@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts)
 
 #### `agent/stream-chunk` — emit
 
@@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
 
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-continuation` — waterfall
 
@@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-end` — emit
 
@@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on
 
 Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md)
 
-Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-start` — emit
 
@@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts)
 
 ### `llm/*`
 
@@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo
 The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent.
 
 ```ts cordis-catalog
-create(id: string, options: AgentOptions = {}): ReactLoopAgent
+create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent
 createAgent(options: CreateAgentOptions): AgentHandle
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts)
 
 ### `ctx.agents` — `AgentRegistry`
 
@@ -304,7 +304,7 @@ setFactory(factory: AgentFactory): () => void
 create(options: CreateAgentOptions): AgentHandle
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 register(agent: Agent): () => void
-get(id: string): Agent | undefined
+get(id: AgentId): Agent | undefined
 list(): Agent[]
 ```
 
@@ -327,17 +327,17 @@ Semantics every implementation must honor:
 abstract resolve(request: BashExecRequest): BashExecSpec
 abstract run(spec: BashExecSpec): Promise<BashRunResult>
 abstract start(spec: BashExecSpec): BashTask
-abstract get(id: string): BashTask | undefined
-abstract ownerOf(id: string): string | undefined
+abstract get(id: BashTaskId): BashTask | undefined
+abstract ownerOf(id: BashTaskId): OwnerToken | undefined
 abstract list(): BashTask[]
-abstract readOutput(id: string): BashTaskRead
-abstract kill(id: string): boolean
+abstract readOutput(id: BashTaskId): BashTaskRead
+abstract kill(id: BashTaskId): boolean
 onTaskDone(listener: BashTaskListener): () => void
 ```
 
 Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md)
 
-Source: [`packages/bash/bash/src/index.ts:58`](../../packages/bash/bash/src/index.ts)
+Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts)
 
 ### `ctx.llm` — `LlmService`
 
@@ -382,11 +382,11 @@ In-memory session store (`ctx.sessions`).
 Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose.
 
 ```ts cordis-catalog
-create(id?: string, options?: CreateSessionOptions): Session
-prepare(id?: string, options?: CreateSessionOptions): Session
+create(id?: SessionId, options?: CreateSessionOptions): Session
+prepare(id?: SessionId, options?: CreateSessionOptions): Session
 enter(session: Session): () => void
 announce(session: Session): void
-get(id: string): Session | undefined
+get(id: SessionId): Session | undefined
 list(): Session[]
 ```
 

+ 5 - 3
docs/core-data-structures/bash.md

@@ -25,7 +25,7 @@ interface BashExecRequest {
    * seam — that is the consumer's job). Absent for foreground runs and for an
    * ownerless background start (a non-agent caller).
    */
-  owner?: string | undefined
+  owner?: OwnerToken | undefined
 }
 ```
 
@@ -44,12 +44,14 @@ interface BashExecSpec {
    * silently-absent property that yields an unowned (cross-session-readable)
    * task. `start()` stores it; `run()` (foreground) ignores it.
    */
-  owner: string | undefined
+  owner: OwnerToken | undefined
 }
 ```
 
 The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task.
 
+Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path.
+
 ## Foreground runs: `BashRunResult`
 
 The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success.
@@ -90,7 +92,7 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT
 
 ```ts type-equiv
 interface BashTask {
-  readonly id: string
+  readonly id: BashTaskId
   readonly command: string
   status: BashTaskStatus
   /** Exit code once finished (null = killed by signal / still running). */

+ 4 - 2
docs/core-data-structures/core.md

@@ -61,13 +61,15 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str
 
 IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings.
 
-Source: [`packages/llm/llm/src/brand.ts`](../../packages/llm/llm/src/brand.ts)
+The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm).
+
+Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts)
 
 ```ts type-equiv
 type Branded<B extends string> = string & { readonly [BRAND]: B }
 ```
 
-The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function.
+The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md).
 
 ## Content blocks and messages
 

+ 9 - 4
docs/module-graph.md

@@ -7,11 +7,15 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
 
 ```mermaid
 graph TD
+  bash --> brand
+  llm --> brand
   bash-local --> bash
   llm-deepseek --> llm
   llm-pi-ai --> llm
+  session --> brand
   session --> llm
   system-prompt --> llm
+  agent --> brand
   agent --> llm
   agent --> session
   llm-replay --> llm
@@ -49,14 +53,15 @@ graph TD
 
 | Package | Depends on |
 | --- | --- |
-| `bash` | — |
-| `llm` | — |
+| `brand` | — |
+| `bash` | `brand` |
+| `llm` | `brand` |
 | `bash-local` | `bash` |
 | `llm-deepseek` | `llm` |
 | `llm-pi-ai` | `llm` |
-| `session` | `llm` |
+| `session` | `brand`, `llm` |
 | `system-prompt` | `llm` |
-| `agent` | `llm`, `session` |
+| `agent` | `brand`, `llm`, `session` |
 | `llm-replay` | `llm`, `session` |
 | `session-persistence` | `session` |
 | `invariants` | `agent`, `llm`, `session` |

+ 1 - 1
docs/rfc/README.md

@@ -61,7 +61,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
 | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
 | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
-| [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
 
 ### Process
 
@@ -116,6 +115,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
 | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
 | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
+| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
 
 ### Process
 

+ 3 - 3
docs/rfc/proposed/architecture/2026-06-20-branded-ids.md → docs/rfc/implemented/architecture/2026-06-20-branded-ids.md

@@ -1,6 +1,6 @@
 # RFC: Branded IDs everywhere they belong
 
-Status: proposed
+Status: implemented (proposed and accepted 2026-06-20)
 
 ## Problem
 
@@ -8,7 +8,7 @@ The harness already brands three identifiers — `CallId` (`packages/llm/llm/src
 
 **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
 
-The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole".
+The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole".
 
 **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map<string, Session>()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map<string, Agent>()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map<string, …>()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap<Agent, string>()`, `loadingIds = new Set<string>()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map<string, …>` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized.
 
@@ -63,6 +63,6 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
 
 ## Risks / what we give up
 
-- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above).
+- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The risk is broad but low-severity: a missed site is a compile error, not a silent bug. It ships as its own PR, converged with Codex, and stacks naturally near the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) work (both touch the session-id / owner-token boundary; if that proposal lands first, `OwnerToken` still stays distinct from the unified id for the decoupling reason above).
 - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id.
 - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control.

+ 1 - 1
docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md

@@ -2,7 +2,7 @@
 
 Status: implemented (proposed and accepted 2026-06-20)
 
-> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../../proposed/architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove.
+> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove.
 
 ## Problem
 

+ 2 - 1
examples/coding-agent/tests/coding-task.e2e.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
 
 /**
@@ -53,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test
     expect(before.status).not.toBe(0)
 
     ctx = await codingHarness(workdir)
-    const agent = ctx.agentLoop.create('e2e-task', {
+    const agent = ctx.agentLoop.create(AgentId('e2e-task'), {
       model: 'deepseek-v4-flash',
       systemPrompt: SYSTEM_PROMPT,
     })

+ 2 - 1
examples/coding-agent/tests/full-loop.e2e.ts

@@ -1,5 +1,6 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import type { Context } from 'cordis'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
 
 /**
@@ -20,7 +21,7 @@ afterEach(async () => {
 describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => {
   it('runs a bash command on request and reports its output', async () => {
     ctx = await codingHarness(process.cwd())
-    const agent = ctx.agentLoop.create('e2e-loop', {
+    const agent = ctx.agentLoop.create(AgentId('e2e-loop'), {
       model: 'deepseek-v4-flash',
       systemPrompt: SYSTEM_PROMPT,
     })

+ 5 - 3
examples/coding-agent/tests/resume.e2e.ts

@@ -4,6 +4,8 @@ import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { Context } from 'cordis'
 import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
 import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
 
 /**
@@ -15,7 +17,7 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.
  */
 
 const SECRET = 'plum-galaxy-1791'
-const SESSION_ID = 'resume-e2e-session'
+const SESSION_ID = SessionId('resume-e2e-session')
 
 let ctx: Context | undefined
 let root: string | undefined
@@ -38,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     // log on disk survives.
     ctx = await codingHarness(process.cwd(), root)
     const first = ctx.agents.create({
-      agentId: 'resume-1',
+      agentId: AgentId('resume-1'),
       sessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT },
     }).agent as ReactLoopAgent
@@ -52,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     // run 1's exchange as conversation history.
     ctx = await codingHarness(process.cwd(), root)
     const resumed = (await ctx.agents.resume({
-      agentId: 'resume-2',
+      agentId: AgentId('resume-2'),
       resumeSessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM_PROMPT },
     })).agent as ReactLoopAgent

+ 4 - 0
knip.json

@@ -17,6 +17,10 @@
       "entry": ["tests/**/*.spec.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
     },
+    "packages/util/brand": {
+      "project": ["src/**/*.ts"],
+      "ignoreDependencies": ["cordis"]
+    },
     "packages/llm/llm-deepseek": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]

+ 9 - 9
packages/bash/bash-local/src/index.ts

@@ -15,8 +15,8 @@
 
 import { Context } from 'cordis'
 import z from 'schemastery'
-import { BashExecutor } from '@deepseek-ai/dsh-bash'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
+import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
 import { runBash } from './run.ts'
 import type { RunInternals, RunningBash } from './run.ts'
 
@@ -50,7 +50,7 @@ interface TrackedTask extends BashTask {
   stdoutOffset: number
   stderrOffset: number
   /** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
-  owner: string | undefined
+  owner: OwnerToken | undefined
 }
 
 /**
@@ -67,7 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
     maxOutputBytes: z.number().default(64_000),
   })
 
-  private tasks = new Map<string, TrackedTask>()
+  private tasks = new Map<BashTaskId, TrackedTask>()
   private nextTaskId = 1
   /** Test seam: timer/spill knobs forwarded to runBash. */
   internals: RunInternals = {}
@@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor {
       signal: spec.signal,
     }, this.internals)
 
-    const id = `bash-${this.nextTaskId++}`
+    const id = BashTaskId(`bash-${this.nextTaskId++}`)
     const task: TrackedTask = {
       id,
       command: spec.command,
@@ -176,11 +176,11 @@ export class LocalBashExecutor extends BashExecutor {
     return task
   }
 
-  get(id: string): BashTask | undefined {
+  get(id: BashTaskId): BashTask | undefined {
     return this.tasks.get(id)
   }
 
-  ownerOf(id: string): string | undefined {
+  ownerOf(id: BashTaskId): OwnerToken | undefined {
     // Unknown id and known-but-ownerless both read as undefined — the consumer
     // treats undefined as "open" and a truly unknown id fails at readOutput/kill.
     return this.tasks.get(id)?.owner
@@ -190,7 +190,7 @@ export class LocalBashExecutor extends BashExecutor {
     return [...this.tasks.values()]
   }
 
-  readOutput(id: string): BashTaskRead {
+  readOutput(id: BashTaskId): BashTaskRead {
     const task = this.tasks.get(id)
     if (!task) throw new Error(`unknown bash task "${id}"`)
 
@@ -213,7 +213,7 @@ export class LocalBashExecutor extends BashExecutor {
     }
   }
 
-  kill(id: string): boolean {
+  kill(id: BashTaskId): boolean {
     const task = this.tasks.get(id)
     if (!task) throw new Error(`unknown bash task "${id}"`)
     if (task.status !== 'running') return false

+ 3 - 3
packages/bash/bash-local/tests/executor.spec.ts

@@ -4,7 +4,7 @@ import { join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
-import type {} from '@deepseek-ai/dsh-bash'
+import { BashTaskId } from '@deepseek-ai/dsh-bash'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
 
@@ -155,7 +155,7 @@ describe('LocalBashExecutor background tasks', () => {
 
   it('readOutput throws for unknown ids', async () => {
     const { bash } = await setup()
-    expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/)
+    expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
   })
 
   it('kill terminates the process group and reports status killed', async () => {
@@ -172,7 +172,7 @@ describe('LocalBashExecutor background tasks', () => {
     const task = bash.start(bash.resolve({ command: 'true' }))
     await task.done
     expect(bash.kill(task.id)).toBe(false)
-    expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/)
+    expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
   })
 
   it('notifies onTaskDone listeners on completion', async () => {

+ 3 - 0
packages/bash/bash-local/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../../vendor/schemastery"
     },
+    {
+      "path": "../../util/brand"
+    },
     {
       "path": "../../bash/bash"
     }

+ 1 - 1
packages/bash/bash/README.md

@@ -28,4 +28,4 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
 
 ## Vocabulary
 
-`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`string | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
+`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.

+ 2 - 0
packages/bash/bash/package.json

@@ -20,9 +20,11 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }
 }

+ 6 - 5
packages/bash/bash/src/index.ts

@@ -15,8 +15,9 @@
  */
 
 import { Context, Service } from 'cordis'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts'
+import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
 
+export { BashTaskId, OwnerToken } from './types.ts'
 export type {
   BashExecRequest,
   BashExecSpec,
@@ -86,7 +87,7 @@ export abstract class BashExecutor extends Service {
   abstract start(spec: BashExecSpec): BashTask
 
   /** Look up a background task by id. */
-  abstract get(id: string): BashTask | undefined
+  abstract get(id: BashTaskId): BashTask | undefined
 
   /**
    * The opaque OWNER token recorded for a background task at {@link start}
@@ -101,19 +102,19 @@ export abstract class BashExecutor extends Service {
    * Storing ownership in the executor (disposed with ITS fiber) — not in the
    * tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
    */
-  abstract ownerOf(id: string): string | undefined
+  abstract ownerOf(id: BashTaskId): OwnerToken | undefined
 
   /** All tracked background tasks (insertion order). */
   abstract list(): BashTask[]
 
   /** Read output produced since the previous read. Throws for unknown ids. */
-  abstract readOutput(id: string): BashTaskRead
+  abstract readOutput(id: BashTaskId): BashTaskRead
 
   /**
    * Kill a running background task. Returns false when it had already
    * finished (no-op). Throws for unknown ids.
    */
-  abstract kill(id: string): boolean
+  abstract kill(id: BashTaskId): boolean
 
   /**
    * Register a background-task completion listener (disposed with the

+ 28 - 3
packages/bash/bash/src/types.ts

@@ -6,6 +6,31 @@
  * @module dsh-bash/types
  */
 
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Identifies one background task within an executor (generated `bash-N`). */
+export type BashTaskId = Branded<'BashTaskId'>
+
+/** Brand a string as a {@link BashTaskId}. */
+export function BashTaskId(id: string): BashTaskId {
+  return id as BashTaskId
+}
+
+/**
+ * A background task's opaque isolation key — the CONSUMER's owner identity, not
+ * the bash seam's. The executor stores and returns it verbatim and never
+ * interprets it; the access policy lives in the consumer (`dsh-tool-bash`),
+ * which is the single boundary that casts its own id vocabulary into one. A
+ * DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled — a
+ * sandboxed/remote executor inherits no session dependency.
+ */
+export type OwnerToken = Branded<'OwnerToken'>
+
+/** Brand a string as an {@link OwnerToken}. */
+export function OwnerToken(id: string): OwnerToken {
+  return id as OwnerToken
+}
+
 /**
  * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
  * filled by {@link BashExecutor.resolve} from the implementation's config.
@@ -28,7 +53,7 @@ export interface BashExecRequest {
    * seam — that is the consumer's job). Absent for foreground runs and for an
    * ownerless background start (a non-agent caller).
    */
-  owner?: string | undefined
+  owner?: OwnerToken | undefined
 }
 
 /**
@@ -53,7 +78,7 @@ export interface BashExecSpec {
    * silently-absent property that yields an unowned (cross-session-readable)
    * task. `start()` stores it; `run()` (foreground) ignores it.
    */
-  owner: string | undefined
+  owner: OwnerToken | undefined
 }
 
 /** One captured stream: the (possibly truncated) text plus recovery info. */
@@ -87,7 +112,7 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
 
 /** A tracked background task handle. */
 export interface BashTask {
-  readonly id: string
+  readonly id: BashTaskId
   readonly command: string
   status: BashTaskStatus
   /** Exit code once finished (null = killed by signal / still running). */

+ 8 - 8
packages/bash/bash/tests/service.spec.ts

@@ -1,12 +1,12 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
-import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
 import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
 
 /** Minimal concrete executor: records calls, lets tests drive completions. */
 class StubExecutor extends BashExecutor {
-  tasks = new Map<string, BashTask>()
-  private owners = new Map<string, string | undefined>()
+  tasks = new Map<BashTaskId, BashTask>()
+  private owners = new Map<BashTaskId, OwnerToken | undefined>()
 
   resolve(request: BashExecRequest): BashExecSpec {
     return {
@@ -32,7 +32,7 @@ class StubExecutor extends BashExecutor {
 
   start(spec: BashExecSpec): BashTask {
     const task: BashTask = {
-      id: `stub-${this.tasks.size + 1}`,
+      id: BashTaskId(`stub-${this.tasks.size + 1}`),
       command: spec.command,
       status: 'running',
       exitCode: null,
@@ -44,11 +44,11 @@ class StubExecutor extends BashExecutor {
     return task
   }
 
-  get(id: string): BashTask | undefined {
+  get(id: BashTaskId): BashTask | undefined {
     return this.tasks.get(id)
   }
 
-  ownerOf(id: string): string | undefined {
+  ownerOf(id: BashTaskId): OwnerToken | undefined {
     return this.owners.get(id)
   }
 
@@ -56,13 +56,13 @@ class StubExecutor extends BashExecutor {
     return [...this.tasks.values()]
   }
 
-  readOutput(id: string): BashTaskRead {
+  readOutput(id: BashTaskId): BashTaskRead {
     const task = this.tasks.get(id)
     if (!task) throw new Error(`unknown bash task "${id}"`)
     return { task, delta: '', lossy: false }
   }
 
-  kill(id: string): boolean {
+  kill(id: BashTaskId): boolean {
     const task = this.tasks.get(id)
     if (!task) throw new Error(`unknown bash task "${id}"`)
     if (task.status !== 'running') return false

+ 3 - 0
packages/bash/bash/tsconfig.json

@@ -13,6 +13,9 @@
     },
     {
       "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/brand"
     }
   ]
 }

+ 7 - 5
packages/bash/tool-bash/src/index.ts

@@ -43,6 +43,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
 import type { Agent } from '@deepseek-ai/dsh-agent'
+import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
 import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
 
 export const name = 'tool-bash'
@@ -79,11 +80,11 @@ function validateBashArgs(args: {
  * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
  * DSL can't express, is left to check here.
  */
-function validateTaskId(value: string): string {
+function validateTaskId(value: string): BashTaskId {
   if (value.length === 0) {
     throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
   }
-  return value
+  return BashTaskId(value)
 }
 
 /** Append the truncation notice (with the full-output spill path) to a stream's text. */
@@ -279,7 +280,8 @@ export function apply(ctx: Context): void {
    * the conventions flag. The two are equal in production, but the header is the
    * canonical identity.
    */
-  const callerToken = (exec: { agent?: Agent }): string | undefined => exec.agent?.session.header.id
+  const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
+    exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
 
   /**
    * Authorize a `bash_output`/`bash_kill` call against the task's stored owner
@@ -291,7 +293,7 @@ export function apply(ctx: Context): void {
    * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
    * (`callerToken` undefined) cannot match an owned task and is rejected.
    */
-  const assertTaskAccess = (taskId: string, exec: { agent?: Agent }): void => {
+  const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
     const owner = ctx.bash.ownerOf(taskId)
     if (owner !== undefined && owner !== callerToken(exec)) {
       throw new Error(`task ${taskId} belongs to another session`)
@@ -310,7 +312,7 @@ export function apply(ctx: Context): void {
   ctx.bash.onTaskDone((task) => {
     const ownerToken = ctx.bash.ownerOf(task.id)
     if (ownerToken === undefined) return
-    const agent = ctx.get('agents')?.list().find(a => a.session.header.id === ownerToken)
+    const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
     if (!agent) return
     try {
       agent.inject(

+ 6 - 5
packages/bash/tool-bash/tests/integration.spec.ts

@@ -5,9 +5,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
+import { BashTaskId } from '@deepseek-ai/dsh-bash'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 
@@ -73,7 +74,7 @@ describe('bash tool through the agent loop', () => {
       textResponse('The command printed integration-ok.'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('it-fg', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
 
     agent.send([{ type: 'text', text: 'run echo integration-ok' }])
     await waitForIdle(ctx, agent)
@@ -105,7 +106,7 @@ describe('bash tool through the agent loop', () => {
       textResponse('It failed with code 9.'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('it-exit', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
 
     agent.send([{ type: 'text', text: 'run exit 9' }])
     await waitForIdle(ctx, agent)
@@ -126,7 +127,7 @@ describe('bash tool through the agent loop', () => {
     let taskId = ''
 
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('it-bg', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
 
     // Intercept the first tool result to capture the generated task id, then
     // rewrite the second scripted call's arguments to use it.
@@ -147,7 +148,7 @@ describe('bash tool through the agent loop', () => {
     await waitForIdle(ctx, agent)
 
     // Wait for the background task itself (completion may race turn end).
-    const task = ctx.bash.get(taskId)
+    const task = ctx.bash.get(BashTaskId(taskId))
     if (!task) throw new Error(`task ${taskId} not registered`)
     await task.done
 

+ 22 - 22
packages/bash/tool-bash/tests/tools.spec.ts

@@ -4,8 +4,8 @@ import { join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { CallId } from '@deepseek-ai/dsh-llm'
-import { BashExecutor } from '@deepseek-ai/dsh-bash'
-import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
+import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string {
 
 class LossyReadBashExecutor extends BashExecutor {
   private readonly task: BashTask = {
-    id: 'bash-lossy',
+    id: BashTaskId('bash-lossy'),
     command: 'fake',
     status: 'running',
     exitCode: null,
@@ -94,11 +94,11 @@ class LossyReadBashExecutor extends BashExecutor {
     return this.task
   }
 
-  get(id: string): BashTask | undefined {
+  get(id: BashTaskId): BashTask | undefined {
     return id === this.task.id ? this.task : undefined
   }
 
-  ownerOf(): string | undefined {
+  ownerOf(): OwnerToken | undefined {
     return undefined
   }
 
@@ -106,7 +106,7 @@ class LossyReadBashExecutor extends BashExecutor {
     return [this.task]
   }
 
-  readOutput(id: string): BashTaskRead {
+  readOutput(id: BashTaskId): BashTaskRead {
     if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
     return { task: this.task, delta: 'tail', lossy: true }
   }
@@ -279,7 +279,7 @@ describe('background tools', () => {
   it('bash_output polls incrementally and reports status', async () => {
     const ctx = await setup()
     const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
 
     await new Promise(resolve => setTimeout(resolve, 150))
     const first = await call(ctx, 'bash_output', { task_id: id })
@@ -305,7 +305,7 @@ describe('background tools', () => {
     await ctx.plugin(ToolBash)
 
     const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     await ctx.bash.get(id)!.done
     const read = await call(ctx, 'bash_output', { task_id: id })
     expect(text(read)).toContain('[some output was dropped from memory; full output: ')
@@ -325,7 +325,7 @@ describe('background tools', () => {
   it('bash_kill stops a running task; repeat reports already-finished', async () => {
     const ctx = await setup()
     const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
 
     const killed = await call(ctx, 'bash_kill', { task_id: id })
     expect(text(killed)).toBe(`killed background task ${id}`)
@@ -372,7 +372,7 @@ describe('background tools', () => {
       arguments: { command: 'true', description: 'test command', run_in_background: true },
       agent,
     })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     await ctx.bash.get(id)!.done
 
     expect(inject).toHaveBeenCalledTimes(1)
@@ -395,7 +395,7 @@ describe('background tools', () => {
       arguments: { command: 'true', description: 'test command', run_in_background: true },
       agent,
     })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
   })
 
@@ -414,7 +414,7 @@ describe('background tools', () => {
         arguments: { command: 'true', description: 'test command', run_in_background: true },
         agent,
       })
-      const id = /task (bash-\d+)/.exec(text(started))![1]!
+      const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
       await ctx.bash.get(id)!.done
       // notifyTaskDone caught and logged the rethrown error.
       expect(errorSpy).toHaveBeenCalled()
@@ -440,7 +440,7 @@ describe('background tools', () => {
       arguments: { command: 'true', description: 'test command', run_in_background: true },
       agent,
     })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     // Unregister the agent BEFORE the task completes (simulate disconnect).
     unregisterFakeAgents(ctx)
     await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
@@ -450,7 +450,7 @@ describe('background tools', () => {
   it('does not notify when no agent owned the task', async () => {
     const ctx = await setup()
     const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
   })
 })
@@ -474,7 +474,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const b = fakeAgent('sess-b')
     // Agent A starts a long-running background task.
     const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
 
     // Agent B (a different session token) cannot read or kill A's task.
     const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
@@ -498,7 +498,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const a1 = fakeAgent('sess-shared')
     const a2 = fakeAgent('sess-shared') // distinct object, same token
     const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
     expect(readByA2.isError).toBe(false)
     await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
@@ -508,7 +508,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const ctx = await setup()
     const a = fakeAgent('sess-a')
     const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     // A call with no exec.agent has no token → cannot prove ownership of an owned task.
     const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
     expect(read.isError).toBe(true)
@@ -520,7 +520,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const ctx = await setup()
     // Started by a non-loop caller (no exec.agent) → no owner token recorded.
     const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     // Any agent (and the no-agent caller) may read/kill it.
     const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
     expect(read.isError).toBe(false)
@@ -533,7 +533,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const a = fakeAgent('sess-a')
     const b = fakeAgent('sess-b')
     const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     await ctx.bash.get(id)!.done
     // Completion does NOT clear ownership: B is still rejected, A still allowed.
     const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
@@ -559,7 +559,7 @@ describe('background task ownership (cross-session isolation)', () => {
     const a = fakeAgent('sess-a')
     const b = fakeAgent('sess-b')
     const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     // Before reload: B is rejected (A owns it).
     expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
 
@@ -674,7 +674,7 @@ describe('status lines', () => {
   it('reports kills without a recorded signal (executor raced process exit)', async () => {
     const ctx = await setup()
     const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     const task = ctx.bash.get(id)!
 
     await call(ctx, 'bash_kill', { task_id: id })
@@ -688,7 +688,7 @@ describe('status lines', () => {
   it('reports completed tasks with a null exit code as exit 0', async () => {
     const ctx = await setup()
     const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
-    const id = /task (bash-\d+)/.exec(text(started))![1]!
+    const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
     const task = ctx.bash.get(id)!
     await task.done
     // Defensive: completed tasks always carry an exit code in practice; the

+ 21 - 13
packages/core/agent-loop/src/index.ts

@@ -10,8 +10,7 @@
 import { Context, Service } from 'cordis'
 import { randomUUID } from 'node:crypto'
 import z from 'schemastery'
-import { AgentId } from '@deepseek-ai/dsh-agent'
-import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
+import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
@@ -33,7 +32,7 @@ declare module 'cordis' {
 export interface Config {
   /** Agents created from configuration at startup. */
   agents: (AgentOptions & {
-    id: string
+    id: AgentId
     /**
      * If set, the config agent RESUMES this persisted session id instead of
      * starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -42,8 +41,12 @@ export interface Config {
      * `dsh-session-persistence` backend; the resume is deferred until that
      * service is available (via `ctx.inject`) and the loaded session's events
      * seed the live session so history continues.
+     *
+     * The schema accepts a plain string at runtime (cordis.yml values are
+     * untyped); the brand is compile-time only — the config format is the
+     * boundary where an id enters, so the TYPE declares the brand here.
      */
-    resumeSessionId?: string
+    resumeSessionId?: SessionId
   })[]
 }
 
@@ -60,14 +63,19 @@ export interface Config {
 export class AgentLoop extends Service implements AgentFactory {
   static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
 
-  static Config: z<Config> = z.object({
+  // The schema validates plain strings (cordis.yml config values are untyped at
+  // runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
+  // because the config format is the boundary where an id enters. The brand is a
+  // zero-cost compile-time cast, so the runtime schema stays string-based and we
+  // assert the branded view once here — the single schema boundary.
+  static Config = z.object({
     agents: z.array(z.object({
       id: z.string().required(),
       model: z.string(),
       systemPrompt: z.string(),
       resumeSessionId: z.string(),
     })).default([]),
-  })
+  }) as unknown as z<Config>
 
   constructor(ctx: Context, public config: Config) {
     super(ctx, 'agentLoop')
@@ -118,14 +126,14 @@ export class AgentLoop extends Service implements AgentFactory {
    * fork seeds the new Session with the parent's event log, spawn starts
    * fresh; the child is returned as a regular Agent handle.
    */
-  create(id: string, options: AgentOptions = {}): ReactLoopAgent {
+  create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
     this.assertAgentIdFree(id)
     // Config/programmatic path: prepare the session and let start() fold its
     // lifecycle into the agent's composite effect (so a fiber unload tears the
     // session + agent down as one ordered chain, capturing the loop's closing
     // flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
-    const session = this.ctx.sessions.prepare(`${id}-session-${randomUUID()}`, { meta: {} })
-    const { agent } = this.start(AgentId(id), options, session)
+    const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
+    const { agent } = this.start(id, options, session)
     return agent
   }
 
@@ -142,7 +150,7 @@ export class AgentLoop extends Service implements AgentFactory {
     // live session (and lazy persistence state) that blocks reuse of that id.
     this.assertAgentIdFree(options.agentId)
     const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
-    return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session)
+    return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
   }
 
   /**
@@ -191,7 +199,7 @@ export class AgentLoop extends Service implements AgentFactory {
    */
   private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
     this.assertAgentIdFree(options.agentId)
-    const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
+    const { meta, events } = await persistence.load(options.resumeSessionId)
     // Re-check the agent id AFTER the await: the pre-load check above can go
     // stale while load() is pending (a concurrent resume/create may register the
     // same id). Re-checking immediately before prepare()/start keeps the
@@ -211,7 +219,7 @@ export class AgentLoop extends Service implements AgentFactory {
         ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
       },
     })
-    return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, session)
+    return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
   }
 
   /**
@@ -220,7 +228,7 @@ export class AgentLoop extends Service implements AgentFactory {
    * persistence state) behind. `register()` enforces the same uniqueness, but
    * only after the session has already entered the store.
    */
-  private assertAgentIdFree(id: string): void {
+  private assertAgentIdFree(id: AgentId): void {
     if (this.ctx.agents.get(id) !== undefined) {
       throw new Error(`agent "${id}" is already registered`)
     }

+ 23 - 23
packages/core/agent-loop/tests/agent.spec.ts

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { AgentId } from '@deepseek-ai/dsh-agent'
 import LlmService from '@deepseek-ai/dsh-llm'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -53,7 +53,7 @@ describe('ReactLoopAgent', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -68,7 +68,7 @@ describe('ReactLoopAgent', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -83,7 +83,7 @@ describe('ReactLoopAgent', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -96,7 +96,7 @@ describe('ReactLoopAgent', () => {
   it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // Simulate an OPEN turn in the log while the agent is idle (status is not a
     // reliable open-turn signal). inject must append into that open turn, NOT
@@ -122,7 +122,7 @@ describe('ReactLoopAgent', () => {
     // A persistence-like listener whose flush rejects.
     ctx.on('session/flush', () => { throw new Error('disk gone') })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // inject() is synchronous and fires a fire-and-forget flush; a rejecting
     // flush must be contained (logged), never thrown into the caller.
@@ -135,7 +135,7 @@ describe('ReactLoopAgent', () => {
   it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     let flushes = 0
     ctx.on('session/flush', () => { flushes += 1 })
 
@@ -155,7 +155,7 @@ describe('ReactLoopAgent', () => {
   it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     let flushes = 0
     ctx.on('session/flush', () => { flushes += 1 })
     // A session/event listener that throws on the synthetic turn/end. Append
@@ -180,7 +180,7 @@ describe('ReactLoopAgent', () => {
     // A non-Error rejection exercises the String() normalization branch.
     ctx.on('session/flush', () => { throw 'disk gone' })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     const errors: { turn: number; step: number; message: string }[] = []
     ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
 
@@ -199,7 +199,7 @@ describe('ReactLoopAgent', () => {
   it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // A non-serializable source makes the turn/start append throw BEFORE the
     // event is pushed (Session.append validates before push), so NO turn opens.
@@ -214,7 +214,7 @@ describe('ReactLoopAgent', () => {
   it('steer() when idle falls through to send() and starts a turn', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // steer while idle delegates to send
     agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -230,7 +230,7 @@ describe('ReactLoopAgent', () => {
     // Then call it twice — the second call hits the early-return branch.
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const session = ctx.sessions.create('test')
+    const session = ctx.sessions.create(SessionId('test'))
     const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
 
     // Start the loop to get the disposer; the agent waits for messages
@@ -249,7 +249,7 @@ describe('ReactLoopAgent', () => {
   it('setting the same status does not emit agent/status again', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const statuses: string[] = []
     ctx.on('agent/status', (subject, status) => {
@@ -268,7 +268,7 @@ describe('ReactLoopAgent', () => {
   it('whenIdle() resolves immediately when the agent is not running', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // Fresh agent is idle — whenIdle() takes the not-running fast path and
     // resolves without subscribing. await must not hang.
@@ -279,7 +279,7 @@ describe('ReactLoopAgent', () => {
   it('whenIdle() waits for queued work that has not flipped status yet', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'queued')
     let settled = false
@@ -297,8 +297,8 @@ describe('ReactLoopAgent', () => {
   it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
     const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
-    const other = ctx.agentLoop.create('a2', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
+    const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
 
     // Drive `agent` into `running`, then await whenIdle() — it subscribes to
     // agent/status and resolves on the first transition out of running.
@@ -333,7 +333,7 @@ describe('ReactLoopAgent', () => {
     await ctx.plugin(AgentRegistry)
     const adapter = new MockAdapter(['hang'])
     ctx.llm.registerAdapter(['mock'], adapter)
-    const session = ctx.sessions.create('bare')
+    const session = ctx.sessions.create(SessionId('bare'))
     const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
     const dispose = agent.start()
     agent.send([{ type: 'text', text: 'go' }])
@@ -357,7 +357,7 @@ describe('ReactLoopAgent', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -378,7 +378,7 @@ describe('ReactLoopAgent', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -399,7 +399,7 @@ describe('ReactLoopAgent', () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.on('agent/status', (_subject, status) => {
       if (status === 'running') throw new Error('bad running listener')
     })
@@ -417,7 +417,7 @@ describe('ReactLoopAgent', () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.on('agent/status', (_subject, status) => {
       if (status === 'idle') throw new Error('bad idle listener')
     })
@@ -434,7 +434,7 @@ describe('ReactLoopAgent', () => {
   it('abort() resolves reason to "aborted" when no reason provided', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: { kind: string; reason?: string }[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))

+ 13 - 13
packages/core/agent-loop/tests/cancel.spec.ts

@@ -15,7 +15,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
@@ -56,7 +56,7 @@ describe('Agent.cancel()', () => {
   it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
     const adapter = new MockAdapter([textResponse('reply')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // The loop is parked at the idle wait with nothing queued. A cancel here must
     // NOT arm the marker — otherwise the next legitimate prompt would be dropped.
@@ -73,7 +73,7 @@ describe('Agent.cancel()', () => {
   it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
     const adapter = new MockAdapter([textResponse('should not run')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // send() queues synchronously (status still idle, loop microtask not yet
     // resumed). Cancel in that pre-step window: the queued turn must not run.
@@ -92,7 +92,7 @@ describe('Agent.cancel()', () => {
   it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
     const adapter = new MockAdapter([textResponse('x')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // Queue work, then register a whenIdle() waiter while in the pre-step window
     // (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
@@ -113,7 +113,7 @@ describe('Agent.cancel()', () => {
   it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
@@ -130,7 +130,7 @@ describe('Agent.cancel()', () => {
   it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
@@ -146,7 +146,7 @@ describe('Agent.cancel()', () => {
   it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
     const adapter = new MockAdapter(['hang', textResponse('second reply')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // First turn hangs; cancel it mid-step.
     send(agent, 'first')
@@ -168,7 +168,7 @@ describe('Agent.cancel()', () => {
   it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => {
     const adapter = new MockAdapter([textResponse('should not stream')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // A turn-start listener fires BEFORE any AbortController is installed for the
     // step. Cancelling there must still drop the step (the turn-scoped marker,
@@ -200,7 +200,7 @@ describe('Agent.cancel()', () => {
     // `aborted` and run NO second step.
     const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steps = 0
     ctx.on('agent/step-start', () => { steps += 1 })
@@ -230,7 +230,7 @@ describe('Agent.cancel()', () => {
   it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
     const adapter = new MockAdapter([textResponse('should not run')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
     // listener can cancel in the gap between the loop's pre-step check and
@@ -260,7 +260,7 @@ describe('Agent.cancel()', () => {
     // so whenIdle() resolves on the replacement turn's running→idle, not before.
     const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let replaced = false
     const dispose = ctx.on('agent/status', (subject, status) => {
@@ -290,7 +290,7 @@ describe('Agent.cancel()', () => {
     // settle (the quiescence contract), not resolve before B's first event.
     const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'A')           // queues A (status still idle, loop microtask pending)
     const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
@@ -310,7 +310,7 @@ describe('Agent.cancel()', () => {
   it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))

+ 11 - 11
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -4,10 +4,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import LlmService from '@deepseek-ai/dsh-llm'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -35,10 +35,10 @@ describe('config-driven session id', () => {
     await ctx1.plugin(SystemPrompt)
     await ctx1.plugin(ToolRegistry)
     await ctx1.plugin(AgentRegistry)
-    await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
+    await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
-    const a1 = ctx1.agents.get('cfg') as ReactLoopAgent
+    const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
     expect(a1.session.id).toMatch(idPattern)
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
@@ -52,10 +52,10 @@ describe('config-driven session id', () => {
     await ctx2.plugin(SystemPrompt)
     await ctx2.plugin(ToolRegistry)
     await ctx2.plugin(AgentRegistry)
-    await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
+    await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
-    const a2 = ctx2.agents.get('cfg') as ReactLoopAgent
+    const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
     expect(a2.session.id).toMatch(idPattern)
     expect(a2.session.id).not.toBe(a1.session.id)
     a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
     await ctx1.plugin(AgentLoop, { agents: [] })
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
-    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent
+    const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
     a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -92,7 +92,7 @@ describe('config-driven session id', () => {
     await ctx2.plugin(SystemPrompt)
     await ctx2.plugin(ToolRegistry)
     await ctx2.plugin(AgentRegistry)
-    await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'sticky-1' }] })
+    await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
 
@@ -100,7 +100,7 @@ describe('config-driven session id', () => {
     let resumed: ReactLoopAgent | undefined
     for (let i = 0; i < 50 && !resumed; i++) {
       await new Promise(r => setTimeout(r, 5))
-      resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined
+      resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined
     }
     expect(resumed).toBeDefined()
     // The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
@@ -120,7 +120,7 @@ describe('config-driven session id', () => {
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRegistry)
     await ctx.plugin(AgentRegistry)
-    await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'does-not-exist' }] })
+    await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] })
     const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
       .mockImplementation(() => undefined)
     await ctx.plugin(SessionPersistenceJsonl, { root })
@@ -129,7 +129,7 @@ describe('config-driven session id', () => {
     // The deferred resume fails (no such session on disk). It must be contained:
     // a warning is logged, no 'main' agent is registered, and the app stays up.
     await new Promise(r => setTimeout(r, 200))
-    expect(ctx.agents.get('main')).toBeUndefined()
+    expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
     warn.mockRestore()
     await ctx.fiber.dispose()

+ 11 - 11
packages/core/agent-loop/tests/coverage-edges.spec.ts

@@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
 import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
@@ -43,7 +43,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
     // The second turn should proceed normally and consume the first script entry.
     const adapter = new MockAdapter([textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/turn-start', () => {
@@ -73,7 +73,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
   it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => {
     const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/turn-end', () => {
@@ -107,7 +107,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
     // driver survives. This is the ONLY path that reaches the backstop.
     const adapter = new MockAdapter([textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const errors: { turn: number; step: number; message: string }[] = []
     ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
@@ -149,7 +149,7 @@ describe('tool JSON parse', () => {
         return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'use tool')
     await waitForIdle(ctx, agent)
@@ -182,7 +182,7 @@ describe('tool JSON parse', () => {
         return [{ type: 'text', text: 'ran with empty args' }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'use tool')
     await waitForIdle(ctx, agent)
@@ -195,7 +195,7 @@ describe('toError normalization', () => {
   it('normalizes non-Error throws from turn-start listeners via toError', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/turn-start', () => {
@@ -221,7 +221,7 @@ describe('toError normalization', () => {
   it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
     const adapter = new MockAdapter([textResponse('irrelevant')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
@@ -249,7 +249,7 @@ describe('coded error data emission', () => {
   it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
     const adapter = new MockAdapter([textResponse('turn 1')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
@@ -283,7 +283,7 @@ describe('disposed vs aborted branching', () => {
     const ctx = await harness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
     const reasons: TurnEndReason[] = []
@@ -311,7 +311,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
       textResponse('done'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.tools.register(defineTool({
       name: 'boom',
       description: 'always fails',

+ 30 - 30
packages/core/agent-loop/tests/loop.spec.ts

@@ -1,10 +1,10 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
-import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
 
@@ -44,7 +44,7 @@ describe('agent loop', () => {
   it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
     const adapter = new MockAdapter([textResponse('hello there')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const order: string[] = []
     for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
@@ -85,7 +85,7 @@ describe('agent loop', () => {
         return [{ type: 'text', text: `echo: ${args.text}` }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'use the tool')
     await waitForIdle(ctx, agent)
@@ -120,7 +120,7 @@ describe('agent loop', () => {
         return []
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
 
     send(agent, 'hi')
     await waitForIdle(ctx, agent)
@@ -133,7 +133,7 @@ describe('agent loop', () => {
   it('records raw chunks for replay and emits agent/stream-chunk', async () => {
     const adapter = new MockAdapter([textResponse('abc')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const streamed: StreamChunk[] = []
     ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
@@ -161,7 +161,7 @@ describe('agent loop', () => {
     ])
     const ctx = await harness(adapter)
 
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.tools.register(defineTool({
       name: 'slow',
       description: '',
@@ -193,7 +193,7 @@ describe('agent loop', () => {
   it('steering while idle behaves like send (starts a turn)', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     agent.steer([{ type: 'text', text: 'hello' }])
     await waitForIdle(ctx, agent)
@@ -203,7 +203,7 @@ describe('agent loop', () => {
   it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
     // The idle inject records a self-contained turn (turn/start → context/message
@@ -230,7 +230,7 @@ describe('agent loop', () => {
       textResponse('done'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     // A tool that injects mid-execution: at this point the agent is running, so
     // inject must append the context/message into the ALREADY-open turn rather
     // than wrap it in its own one-shot turn.
@@ -264,7 +264,7 @@ describe('agent loop', () => {
       textResponse('step 3'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steps = 0
     ctx.on('agent/step-end', () => void steps++)
@@ -290,7 +290,7 @@ describe('agent loop', () => {
         return [{ type: 'text', text: String(args.text) }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     ctx.on('agent/turn-continuation', async () => false as const)
 
@@ -306,7 +306,7 @@ describe('agent loop', () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
     ctx.llm.registerAdapter(['other-model'], adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
       options.model = 'other-model'
@@ -321,7 +321,7 @@ describe('agent loop', () => {
   it('abort() mid-stream ends the turn with reason aborted', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -341,7 +341,7 @@ describe('agent loop', () => {
     // turn stops by default and ends max-tokens, not completed.
     const adapter = new MockAdapter([maxTokensResponse('truncat')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -366,7 +366,7 @@ describe('agent loop', () => {
       textResponse('second half'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steps = 0
     ctx.on('agent/step-end', () => void steps++)
@@ -397,7 +397,7 @@ describe('agent loop', () => {
     // stop. The per-turn reason must be independent — turn 2 ends completed.
     const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -430,7 +430,7 @@ describe('agent loop', () => {
         return [{ type: 'text', text: 'should not run' }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -461,7 +461,7 @@ describe('agent loop', () => {
       expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
       return next()
     })
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'go')
     await waitForIdle(ctx, agent)
@@ -488,7 +488,7 @@ describe('agent loop', () => {
         return [{ type: 'text', text: String(args.text) }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     let threw = false
     ctx.on('agent/step-end', () => {
       if (!threw) { threw = true; throw new Error('bad step-end listener') }
@@ -505,7 +505,7 @@ describe('agent loop', () => {
   it('chains queued messages into consecutive turns', async () => {
     const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const turns: number[] = []
     ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
@@ -530,7 +530,7 @@ describe('agent loop', () => {
   it('awaits session/flush at turn end (persistence checkpoint)', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let flushed = 0
     let flushedBeforeIdle = false
@@ -550,7 +550,7 @@ describe('agent loop', () => {
   it('errors from the model surface as agent/error and end the turn', async () => {
     const adapter = new MockAdapter([]) // script exhausted → throws
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     const errors: Error[] = []
     const reasons: TurnEndReason[] = []
@@ -572,10 +572,10 @@ describe('agent loop', () => {
 
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
-    expect(ctx.agents.get('scoped')).toBe(agent)
+    expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     expect(agent.status).toBe('running')
@@ -584,7 +584,7 @@ describe('agent loop', () => {
     await agent.done
 
     expect(agent.status).toBe('disposed')
-    expect(ctx.agents.get('scoped')).toBeUndefined()
+    expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined()
     expect(() => { send(agent, 'too late') }).toThrow('disposed')
   })
 
@@ -597,11 +597,11 @@ describe('agent loop', () => {
     await ctx.plugin(ToolRegistry)
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(AgentLoop, {
-      agents: [{ id: 'config-agent', model: 'mock', systemPrompt: 'Config prompt' }],
+      agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }],
     })
     ctx.llm.registerAdapter(['mock'], adapter)
 
-    const agent = ctx.agents.get('config-agent')! as ReactLoopAgent
+    const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
     expect(agent).toBeDefined()
     expect(agent.id).toBe('config-agent')
     expect(agent.options.model).toBe('mock')
@@ -626,11 +626,11 @@ describe('agent loop', () => {
         return [{ type: 'text', text: String(args.text) }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     send(agent, 'run')
     await waitForIdle(ctx, agent)
 
-    const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] })
+    const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
     expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
     // event-by-event identity of types
     expect(replayed.events.map(e => e.type)).toEqual(

+ 4 - 4
packages/core/agent-loop/tests/properties.spec.ts

@@ -17,7 +17,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import fc from 'fast-check'
 
@@ -95,7 +95,7 @@ describe('agent loop scheduling properties', () => {
       async (texts) => {
         const ctx = await harness()
         try {
-          const agent = ctx.agentLoop.create('a', { model: 'mock' })
+          const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
           const { seen: trace } = recordStatus(ctx, agent)
           const idle = nextIdle(ctx, agent)
           // Send all in one synchronous tick: they queue before the loop wakes.
@@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => {
       async (texts) => {
         const ctx = await harness()
         try {
-          const agent = ctx.agentLoop.create('a', { model: 'mock' })
+          const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
           for (const text of texts) {
             const idle = nextIdle(ctx, agent)
             agent.send([{ type: 'text', text }])
@@ -145,7 +145,7 @@ describe('agent loop scheduling properties', () => {
       async (steps) => {
         const ctx = await harness()
         try {
-          const agent = ctx.agentLoop.create('a', { model: 'mock' })
+          const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
           // Capture an idle waiter before EACH send; the last one is guaranteed
           // to resolve because the final send always triggers (or joins) a turn
           // that ends idle. Awaiting an already-resolved waiter is a no-op, so a

+ 16 - 16
packages/core/agent-loop/tests/resume.spec.ts

@@ -8,7 +8,7 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
 import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
   it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
     const adapter = new MockAdapter([textResponse('hi')])
     const { ctx } = await persistentHarness(adapter)
-    const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
+    const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
     expect(agent.session.id).toBe('custom-session')
     expect(agent.session.header.cwd).toBe('/w')
     await ctx.fiber.dispose()
@@ -52,18 +52,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
   it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
     const adapter = new MockAdapter([textResponse('hi')])
     const { ctx } = await persistentHarness(adapter)
-    ctx.agents.create({ agentId: 'dup', sessionId: 'sess-a' })
+    ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
     // A second create with the SAME agent id but a fresh session id must reject
     // up front — and must NOT leave an orphaned 'sess-b' session behind.
-    expect(() => ctx.agents.create({ agentId: 'dup', sessionId: 'sess-b' })).toThrow(/already registered/)
-    expect(ctx.sessions.get('sess-b')).toBeUndefined()
+    expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
+    expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
     await ctx.fiber.dispose()
   })
 
   it('createAgent works without meta (no cwd)', async () => {
     const adapter = new MockAdapter([textResponse('hi')])
     const { ctx } = await persistentHarness(adapter)
-    const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
+    const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
     expect(agent.session.id).toBe('nometa-session')
     expect(agent.session.header.cwd).toBeUndefined()
     await ctx.fiber.dispose()
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // Lifecycle 1: create a no-cwd session and run a turn.
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent
+    const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
     expect(a2.session.header.cwd).toBeUndefined()
     await ctx2.fiber.dispose()
   })
@@ -104,7 +104,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     ]
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const forked = ctx1.sessions.create('forked-sess', { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
+    const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
     await ctx1.parallel('session/flush', forked)
     await ctx1.fiber.dispose()
 
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
     expect(a2.session.header.parentSession).toBe('parent-sess')
     expect(a2.session.header.cwd).toBe('/w')
     await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // disk, since a crash before the next turn would otherwise lose it.
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
+    const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // drop it on reload (the bug this guards).
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
+    const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(AgentLoop, { agents: [] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
-    const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
     const flat = JSON.stringify(a2.session.deriveMessages())
     expect(flat).toContain('background task 42 finished')
     await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     // Lifecycle 1: run one full turn, persisting it.
     const adapter1 = new MockAdapter([textResponse('first answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
-    const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent
+    const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
     a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], adapter2)
 
-    const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
     // The resumed session carries the prior history…
     expect(a2.session.id).toBe('sess-resume')
     expect(a2.session.events.length).toBe(events1.length)
@@ -234,7 +234,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(AgentLoop, { agents: [] })
     ctx.llm.registerAdapter(['mock'], adapter)
-    await expect(ctx.agents.resume({ agentId: 'm', resumeSessionId: 'nope' }))
+    await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
       .rejects.toThrow(/session persistence is not configured/)
     await ctx.fiber.dispose()
   })

+ 33 - 33
packages/core/agent-loop/tests/review-fixes.spec.ts

@@ -55,7 +55,7 @@ describe('HIGH: session log records what agent/step-result actually produced', (
         return [{ type: 'text', text: 'ran' }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     // Plugin rewrites the message: replaces the text AND adds a tool call.
     let rewritten = false
@@ -106,7 +106,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
     ])
     const ctx = await harness(adapter)
     const executed: string[] = []
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.tools.register(defineTool({
       name: 'aborter',
       description: '',
@@ -154,7 +154,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
         return [{ type: 'text', text: String(args.text) }]
       },
     }))
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steeredOnce = false
     ctx.on('agent/step-end', () => {
@@ -176,7 +176,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
       textResponse('continued because of steering'),
     ])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steeredOnce = false
     ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
@@ -198,7 +198,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
   it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => {
     const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let steeredOnce = false
     ctx.on('agent/turn-end', () => {
@@ -223,7 +223,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
   it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
     const adapter = new MockAdapter(['hang', textResponse('recovered')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
@@ -241,7 +241,7 @@ describe('HIGH: plugin exceptions are contained', () => {
   it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
     const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let threwOnce = false
     ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
@@ -269,7 +269,7 @@ describe('HIGH: plugin exceptions are contained', () => {
   it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
     const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
     let rejectedOnce = false
     ctx.on('session/flush', async () => {
@@ -299,7 +299,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
 
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
     const statuses: string[] = []
@@ -322,7 +322,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
 
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
     ctx.on('agent/status', (_agent, status) => {
@@ -335,7 +335,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     await agent.done // must not hang
 
     expect(agent.status).toBe('disposed')
-    expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw
+    expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw
   })
 })
 
@@ -354,7 +354,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
   it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
     const adapter = new MockAdapter([textResponse('never')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', {}) // no model
+    const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model
 
     const errors: Error[] = []
     ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
@@ -369,7 +369,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
   it('the agent/request waterfall can supply the model for a model-less agent', async () => {
     const adapter = new MockAdapter([textResponse('routed')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', {}) // no model — router plugin decides
+    const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
 
     ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
       options.model = 'mock'
@@ -385,7 +385,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
   it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
     const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     ctx.tools.register(defineTool({
       name: 'noop',
       description: '',
@@ -414,7 +414,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
   it('a forked agent continues turn numbers after the seed log', async () => {
     const first = new MockAdapter([textResponse('turn one')])
     const ctx = await harness(first)
-    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
     send(agent, 'first')
     await waitForIdle(ctx, agent)
 
@@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
     await ctx2.plugin(AgentLoop, { agents: [] })
     ctx2.llm.registerAdapter(['mock'], second)
 
-    const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
+    const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
     const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
     ctx2.effect(() => forked.start())
 
@@ -475,7 +475,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
     ]
     const adapter = new MockAdapter([errorStream])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-finish-error', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -498,7 +498,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
     ]
     const adapter = new MockAdapter([abortedStream])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-finish-aborted', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -516,7 +516,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
     ]
     const adapter = new MockAdapter([errorStream])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-finish-error-nocode', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' })
 
     const reasons: TurnEndReason[] = []
     ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -532,7 +532,7 @@ describe('P1-6: step/start is appended before agent/step-start is emitted', () =
   it('a step-start listener sees the step/start event already in session.events', async () => {
     const adapter = new MockAdapter([textResponse('done')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-step-order', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' })
 
     // Capture, at the moment agent/step-start fires, whether the matching
     // step/start event is already in the log (append-before-emit, the event-sourcing RFC).
@@ -591,7 +591,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
   it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => {
     const adapter = new MockAdapter([textResponse('never reached')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-turnstart', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } })
@@ -613,7 +613,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
   it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => {
     const adapter = new MockAdapter([textResponse('never reached')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-stepstart', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } })
@@ -642,7 +642,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
     const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-errorlistener', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
@@ -675,7 +675,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     const ctx = await balancedHarness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('a-dispose', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
     const reasons: TurnEndReason[] = []
@@ -706,7 +706,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     const ctx = await balancedHarness(adapter)
     let agent!: ReactLoopAgent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' })
+      agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
 
     // The FIRST agent/turn-end emit throws (the disposal-driven turn end).
@@ -749,7 +749,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // subscriber.)
     const adapter = new MockAdapter([textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-preturn', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
 
     let threw = false
     ctx.on('session/event', (_session, event) => {
@@ -788,7 +788,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // surfaced via agent/error instead, and the log's last event is turn/end.
     const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-tend', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
@@ -820,7 +820,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // swallowed the throw in the normal (no-tool, no-steering) path.
     const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-stepend-throw', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } })
@@ -862,7 +862,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }]
     const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
     const ctx = await balancedHarness(adapter)
-    const agent = ctx.agentLoop.create('a-double', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' })
 
     let threw = false
     ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
@@ -897,7 +897,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }]
     const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-errthrow', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-errthrow'), { model: 'mock' })
 
     let threw = false
     ctx.on('session/event', (_s, event) => {
@@ -930,7 +930,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // throw is contained + surfaced via failTurn, so turn/end is still appended.
     const adapter = new MockAdapter([textResponse('never reached')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-stependthrow', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
 
     // Open a step, then make the agent/step-start emit throw (boundary throw →
     // outer catch → closeStep during finalization).
@@ -968,7 +968,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
     // what throws.)
     const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
     const ctx = await harness(adapter)
-    const agent = ctx.agentLoop.create('a-turnendappend', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
 
     let threw = false
     ctx.on('session/event', (_s, event) => {
@@ -1014,7 +1014,7 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
       return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
     }, { prepend: true })
 
-    const agent = ctx.agentLoop.create('a-callid', { model: 'mock' })
+    const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
     send(agent, 'use tool')
     await waitForIdle(ctx, agent)
 

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

@@ -9,7 +9,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
 ### Public API
 
 - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
-- `ctx.agents.get(id: string): Agent | undefined`
+- `ctx.agents.get(id: AgentId): Agent | undefined`
 - `ctx.agents.list(): Agent[]`
 
 #### Factory seam (creation)

+ 2 - 0
packages/core/agent/package.json

@@ -20,11 +20,13 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "cordis": "^4.0.0-rc.6"

+ 7 - 7
packages/core/agent/src/index.ts

@@ -7,7 +7,7 @@
 
 import { Context, Service } from 'cordis'
 import type { SessionId } from '@deepseek-ai/dsh-session'
-import type { Agent, AgentOptions } from './types.ts'
+import type { Agent, AgentId, AgentOptions } from './types.ts'
 
 export * from './types.ts'
 
@@ -26,9 +26,9 @@ declare module 'cordis' {
  */
 export interface CreateAgentOptions {
   /** The agent's id (the registry handle). */
-  agentId: string
+  agentId: AgentId
   /** The live session's id (NOT derived from agentId). */
-  sessionId: string
+  sessionId: SessionId
   /**
    * Session creation metadata: validated absolute `cwd` and `parentSession`
    * fork lineage. Mirrors the `cwd`/`parentSession` fields of
@@ -47,9 +47,9 @@ export interface CreateAgentOptions {
  */
 export interface ResumeAgentOptions {
   /** The agent's id (the registry handle). */
-  agentId: string
+  agentId: AgentId
   /** The persisted session id to load and resume on. */
-  resumeSessionId: string
+  resumeSessionId: SessionId
   /** Per-agent options (model, system prompt). */
   agentOptions?: AgentOptions
 }
@@ -103,7 +103,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
  * {@link setFactory}.
  */
 export class AgentRegistry extends Service {
-  private store = new Map<string, Agent>()
+  private store = new Map<AgentId, Agent>()
   private factory: AgentFactory | undefined
 
   constructor(ctx: Context) {
@@ -188,7 +188,7 @@ export class AgentRegistry extends Service {
     return () => void dispose()
   }
 
-  get(id: string): Agent | undefined {
+  get(id: AgentId): Agent | undefined {
     return this.store.get(id)
   }
 

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

@@ -9,7 +9,8 @@
  * @module @deepseek-ai/dsh-agent/types
  */
 
-import type { Branded, ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
 
 /** Identifies one live agent in the registry. */
 export type AgentId = Branded<'AgentId'>

+ 12 - 12
packages/core/agent/tests/agent.spec.ts

@@ -32,12 +32,12 @@ describe('AgentRegistry', () => {
     const agent = stubAgent('a1')
     const dispose = ctx.agents.register(agent)
     expect(created).toEqual(['a1'])
-    expect(ctx.agents.get('a1')).toBe(agent)
+    expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
     expect(ctx.agents.list()).toEqual([agent])
 
     dispose()
     expect(disposed).toEqual(['a1'])
-    expect(ctx.agents.get('a1')).toBeUndefined()
+    expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
   })
 
   it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
@@ -66,14 +66,14 @@ describe('AgentRegistry', () => {
 
     // The throwing emit must roll the entry back, not leak it.
     expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
-    expect(ctx.agents.get('main')).toBeUndefined() // rolled back, not leaked
+    expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
 
     // A subsequent listener-free register of the SAME id succeeds and is
     // tracked exactly once (the duplicate-id check is not wedged).
     const dispose = ctx.agents.register(stubAgent('main'))
     expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
     dispose()
-    expect(ctx.agents.get('main')).toBeUndefined()
+    expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
   })
 })
 
@@ -97,8 +97,8 @@ describe('AgentRegistry factory seam', () => {
   it('create()/resume() throw when no factory is registered', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
-    expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).toThrow(/no agent factory/)
-    await expect(ctx.agents.resume({ agentId: 'a', resumeSessionId: 's' })).rejects.toThrow(/no agent factory/)
+    expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
+    await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
   })
 
   it('setFactory registers a factory; create/resume delegate to it', async () => {
@@ -107,13 +107,13 @@ describe('AgentRegistry factory seam', () => {
     const { factory, calls } = stubFactory()
     ctx.agents.setFactory(factory)
 
-    const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } })
+    const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
     expect(created.agent.id).toBe('c1')
-    expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }])
+    expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
 
-    const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' })
+    const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
     expect(resumed.agent.id).toBe('r1')
-    expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }])
+    expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
   })
 
   it('setFactory rejects a second factory', async () => {
@@ -130,10 +130,10 @@ describe('AgentRegistry factory seam', () => {
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       dispose = inner.agents.setFactory(stubFactory().factory)
     }, { inject: ['agents'] }))
-    expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).not.toThrow()
+    expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
     void dispose
     await fiber.dispose()
     // factory slot cleared → create throws again
-    expect(() => ctx.agents.create({ agentId: 'a2', sessionId: 's2' })).toThrow(/no agent factory/)
+    expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
   })
 })

+ 3 - 0
packages/core/agent/tsconfig.json

@@ -14,6 +14,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../util/brand"
+    },
     {
       "path": "../../llm/llm"
     },

+ 2 - 2
packages/core/session/README.md

@@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
 
 ### Public API
 
-- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
-- `ctx.sessions.get(id: string): Session | undefined`
+- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
+- `ctx.sessions.get(id: SessionId): Session | undefined`
 - `ctx.sessions.list(): Session[]`
 
 #### Advanced: ordered-teardown lifecycle primitives

+ 2 - 0
packages/core/session/package.json

@@ -20,10 +20,12 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }

+ 4 - 4
packages/core/session/src/index.ts

@@ -220,7 +220,7 @@ export class Session {
  * subscribe to `session/event` and flush on `session/flush` / dispose.
  */
 export class SessionStore extends Service {
-  private store = new Map<string, Session>()
+  private store = new Map<SessionId, Session>()
   private counter = 0
 
   constructor(ctx: Context) {
@@ -244,7 +244,7 @@ export class SessionStore extends Service {
    * @throws if a session with `id` already exists, or if `meta.cwd` is a
    *   non-absolute path (storage backends key directories off it).
    */
-  create(id?: string, options?: CreateSessionOptions): Session {
+  create(id?: SessionId, options?: CreateSessionOptions): Session {
     const session = this.prepare(id, options)
     // Single effect owned by the calling fiber. Yield the detach BEFORE
     // announcing so a throwing `session/created` listener rolls the attach back
@@ -269,7 +269,7 @@ export class SessionStore extends Service {
    * @throws if a session with `id` already exists, or if `meta.cwd` is a
    *   non-absolute path.
    */
-  prepare(id?: string, options?: CreateSessionOptions): Session {
+  prepare(id?: SessionId, options?: CreateSessionOptions): Session {
     const sessionId = SessionId(id ?? `session-${++this.counter}`)
     if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
     const cwd = options?.meta?.cwd
@@ -321,7 +321,7 @@ export class SessionStore extends Service {
     this.ctx.emit('session/created', session)
   }
 
-  get(id: string): Session | undefined {
+  get(id: SessionId): Session | undefined {
     return this.store.get(id)
   }
 

+ 2 - 1
packages/core/session/src/types.ts

@@ -1,4 +1,5 @@
-import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
 
 /** Identifies one session in the store (and its persistence artifacts). */
 export type SessionId = Branded<'SessionId'>

+ 21 - 21
packages/core/session/tests/session.spec.ts

@@ -213,11 +213,11 @@ describe('SessionStore', () => {
   it('rejects duplicate ids and supports seeding', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const a = ctx.sessions.create('fixed')
-    expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
+    const a = ctx.sessions.create(SessionId('fixed'))
+    expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
 
     a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
-    const forked = ctx.sessions.create('fork', { seed: [...a.events] })
+    const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
     expect(forked.deriveMessages()).toEqual(a.deriveMessages())
   })
 
@@ -228,11 +228,11 @@ describe('SessionStore', () => {
     // the REAL session, breaking the store-uniqueness invariant.
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const stale = ctx.sessions.prepare('racy')
-    const live = ctx.sessions.create('racy')
+    const stale = ctx.sessions.prepare(SessionId('racy'))
+    const live = ctx.sessions.create(SessionId('racy'))
     expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
     // The live session is intact and still the store entry.
-    expect(ctx.sessions.get('racy')).toBe(live)
+    expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
   })
 
   it('prepare() + enter() + announce() register a session and emit session/created', async () => {
@@ -241,24 +241,24 @@ describe('SessionStore', () => {
     const created: Session[] = []
     ctx.on('session/created', session => void created.push(session))
 
-    const session = ctx.sessions.prepare('lifecycle')
+    const session = ctx.sessions.prepare(SessionId('lifecycle'))
     // prepare alone does NOT enter the store.
-    expect(ctx.sessions.get('lifecycle')).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
     const detach = ctx.sessions.enter(session)
-    expect(ctx.sessions.get('lifecycle')).toBe(session)
+    expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
     // enter does NOT announce.
     expect(created).toEqual([])
     ctx.sessions.announce(session)
     expect(created).toEqual([session])
     // The detach disposer removes the entry + stops notification.
     detach()
-    expect(ctx.sessions.get('lifecycle')).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
   })
 
   it('synthesizes a minimal v1 header for a bare-created session', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const session = ctx.sessions.create('plain')
+    const session = ctx.sessions.create(SessionId('plain'))
     expect(session.header).toMatchObject({ version: 1, id: 'plain' })
     expect(typeof session.header.createdAt).toBe('number')
     expect(session.header.cwd).toBeUndefined()
@@ -268,7 +268,7 @@ describe('SessionStore', () => {
   it('attaches cwd and parentSession from meta to the header', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    const session = ctx.sessions.create('child', {
+    const session = ctx.sessions.create(SessionId('child'), {
       meta: { cwd: '/work/project', parentSession: SessionId('parent') },
     })
     expect(session.header).toMatchObject({
@@ -282,10 +282,10 @@ describe('SessionStore', () => {
   it('rejects a non-absolute meta.cwd', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
+    expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
       .toThrow(/cwd must be an absolute path/)
     // the rejected session was not registered
-    expect(ctx.sessions.get('rel')).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
   })
 
   it('a bare Session() constructed without the store still exposes a v1 header', () => {
@@ -300,15 +300,15 @@ describe('SessionStore', () => {
 
     let session!: Session
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
-      session = inner.sessions.create('scoped')
+      session = inner.sessions.create(SessionId('scoped'))
     }, { inject: ['sessions'] }))
-    expect(ctx.sessions.get('scoped')).toBe(session)
+    expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
 
     let observed = 0
     ctx.on('session/event', () => void observed++)
 
     await fiber.dispose()
-    expect(ctx.sessions.get('scoped')).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
     session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
     expect(observed).toBe(0)
   })
@@ -323,15 +323,15 @@ describe('SessionStore', () => {
     })
 
     // The throwing emit must roll the store entry back, not leak it.
-    expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
-    expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
+    expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
+    expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
 
     // A subsequent create of the SAME id succeeds (the already-exists check is
     // not wedged) and its onAppend is correctly wired (events observable).
     const events: SessionEvent[] = []
     ctx.on('session/event', (_session, event) => void events.push(event))
-    const session = ctx.sessions.create('fixed')
-    expect(ctx.sessions.get('fixed')).toBe(session)
+    const session = ctx.sessions.create(SessionId('fixed'))
+    expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
     session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
     expect(events).toHaveLength(1)
   })

+ 3 - 0
packages/core/session/tsconfig.json

@@ -14,6 +14,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../util/brand"
+    },
     {
       "path": "../../llm/llm"
     }

+ 4 - 3
packages/llm/llm-pi-ai/src/adapter.ts

@@ -14,6 +14,7 @@
 import { stream as piStream } from '@earendil-works/pi-ai'
 import type { Model } from '@earendil-works/pi-ai'
 import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
+import { CallId } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
 import { toPiContext, toStreamChunks } from './convert.ts'
 
@@ -69,8 +70,8 @@ type Payload = {
   stop?: unknown
 }
 
-function rawToolArguments(options: GenerateOptions): Map<string, string> {
-  const raw = new Map<string, string>()
+function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
+  const raw = new Map<CallId, string>()
   for (const message of options.messages) {
     if (message.role !== 'assistant') continue
     for (const block of message.content) {
@@ -116,7 +117,7 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA
     for (const call of message.tool_calls ?? []) {
       /* v8 ignore next -- malformed pi-ai payload guard: real tool calls always carry a string id */
       if (typeof call.id !== 'string') continue
-      const raw = rawById.get(call.id)
+      const raw = rawById.get(CallId(call.id))
       /* v8 ignore next -- pi-ai always emits a function object for assistant tool_calls; guard malformed payloads defensively */
       if (raw !== undefined && call.function !== undefined) call.function.arguments = raw
     }

+ 1 - 1
packages/llm/llm-pi-ai/src/convert.ts

@@ -57,7 +57,7 @@ function parseArguments(raw: string): Record<string, unknown> {
  * same id.
  */
 export function toPiContext(options: GenerateOptions): PiContext {
-  const toolNames = new Map<string, string>()
+  const toolNames = new Map<CallId, string>()
   const messages: PiMessage[] = []
 
   for (const message of options.messages) {

+ 2 - 0
packages/llm/llm/package.json

@@ -20,9 +20,11 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
     "cordis": "^4.0.0-rc.6"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "cordis": "^4.0.0-rc.6"
   }
 }

+ 6 - 15
packages/llm/llm/src/brand.ts

@@ -1,24 +1,15 @@
 /**
- * Branded (nominal) ID types.
+ * dsh-llm's owned branded id: `CallId` (tool-call correlation).
  *
- * A brand makes structurally-identical strings non-interchangeable at the
- * type level: an `AgentId` cannot be passed where a `CallId` is expected,
- * even though both are strings at runtime. Construction goes through the
- * per-type factory (a plain cast inside — zero runtime cost); comparison,
- * logging, and serialization all behave as ordinary strings.
- *
- * Policy: core packages brand the IDs they own — `CallId` here (tool-call
- * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent. Branding
- * is for IDs that cross package boundaries and could plausibly be confused;
- * not every string needs a brand.
+ * The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
+ * zero-dependency type-only package) so every owner of a cross-boundary id can
+ * brand it without depending on dsh-llm; see that package's README for the
+ * nominal-typing policy.
  *
  * @module @deepseek-ai/dsh-llm/brand
  */
 
-declare const BRAND: unique symbol
-
-/** A string carrying a compile-time-only brand `B`. */
-export type Branded<B extends string> = string & { readonly [BRAND]: B }
+import type { Branded } from '@deepseek-ai/dsh-brand'
 
 /**
  * Correlates a model-issued tool call with its result. Provider-issued for

+ 3 - 0
packages/llm/llm/tsconfig.json

@@ -13,6 +13,9 @@
     },
     {
       "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/brand"
     }
   ]
 }

+ 11 - 11
packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -274,8 +274,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionPersistenceJsonl, { root })
 
-    const a = ctx.sessions.create('sa')
-    const b = ctx.sessions.create('sb')
+    const a = ctx.sessions.create(SessionId('sa'))
+    const b = ctx.sessions.create(SessionId('sb'))
     a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } })
     b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } })
     a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -451,7 +451,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
   it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
     // Session A materializes a log under id "reuse".
     const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
-      const a = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
+      const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
       for (const e of oneTurnLog()) a.append(e.type, e.data)
     }, { inject: ['sessions'] }))
     // Drain A, then dispose ITS fiber (the live session A is gone) while the
@@ -466,7 +466,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
     let b!: Session
     await ctx.plugin(Object.assign((inner: Context) => {
-      b = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
+      b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
     }, { inject: ['sessions'] }))
     await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
   })
@@ -493,7 +493,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
     let b!: Session
     await ctx2.plugin(Object.assign((inner: Context) => {
-      b = inner.sessions.create('x') // no cwd
+      b = inner.sessions.create(SessionId('x')) // no cwd
     }, { inject: ['sessions'] }))
     await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/)
 
@@ -521,7 +521,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
     let bad!: Session
     await ctx.plugin(Object.assign((inner: Context) => {
-      bad = inner.sessions.create('divergent', { seed: tampered, meta: { cwd: '/a' } })
+      bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
     }, { inject: ['sessions'] }))
     await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
   })
@@ -529,7 +529,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
   it('a second live session reusing a bound id is rejected', async () => {
     // A live session materializes and owns the id.
     const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
-      const a = inner.sessions.create('bound', { meta: { cwd: '/a' } })
+      const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
       a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
       a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     }, { inject: ['sessions'] }))
@@ -539,7 +539,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
     let second!: Session
     await ctx.plugin(Object.assign((inner: Context) => {
-      second = inner.sessions.create('bound', { meta: { cwd: '/a' } })
+      second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
     }, { inject: ['sessions'] }))
     await expect(backend.inits.get(second))
       .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
@@ -580,7 +580,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
     let s!: Session
     await ctx2.plugin(Object.assign((inner: Context) => {
-      s = inner.sessions.create('exists-fault', { meta: { cwd } })
+      s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
     }, { inject: ['sessions'] }))
     await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/)
     await ctx2.fiber.dispose()
@@ -645,7 +645,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
     const ctx2 = new Context()
     await ctx2.plugin(SessionStore)
     await ctx2.plugin(SessionPersistenceJsonl, { root })
-    const session = ctx2.sessions.create('flush-fail')
+    const session = ctx2.sessions.create(SessionId('flush-fail'))
     // A full turn lands in the write-behind buffer.
     session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -689,7 +689,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
   })
 
   it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
-    const session = ctx.sessions.create('reject-bad')
+    const session = ctx.sessions.create(SessionId('reject-bad'))
     // Serializability is enforced at the source: Session.append throws on a
     // BigInt-bearing event BEFORE it enters session.events, so the durable log
     // can never diverge from the live log. The throw surfaces at the caller's

+ 3 - 3
packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
 import { openDatabase, scanRows, type EventRow } from '../src/schema.ts'
@@ -352,7 +352,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
     const path = await freshDbPath()
     // Instance 1 materializes a session and disposes.
     const b1 = await backend(path)
-    const s1 = b1.ctx.sessions.create('hmr-collide')
+    const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
     for (const e of oneTurnLog()) s1.append(e.type, e.data)
     await b1.ctx.parallel('session/flush', s1)
     await b1.dispose()
@@ -363,7 +363,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
     await ctx.plugin(SessionStore)
     let session!: Session
     await ctx.plugin(Object.assign((inner: Context) => {
-      session = inner.sessions.create('hmr-collide')
+      session = inner.sessions.create(SessionId('hmr-collide'))
     }, { inject: ['sessions'] }))
     session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
     await ctx.plugin(SessionPersistenceSqlite, { path })

+ 2 - 2
packages/session-persistence/session-persistence/src/coordinator.ts

@@ -157,14 +157,14 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
  */
 export class PersistenceCoordinator<TornMarker = unknown> {
   /** Backend bookkeeping keyed by session id (NOT the live Session object). */
-  private states = new Map<string, SessionState>()
+  private states = new Map<SessionId, SessionState>()
   /** Write-behind buffers keyed by the live Session (write path). */
   private buffers = new Map<Session, SessionEvent[]>()
   /**
    * Per-session serialization: every operation chains onto the prior one for the
    * same id, so writes for one session never interleave. Keyed by session id.
    */
-  private chains = new Map<string, Promise<unknown>>()
+  private chains = new Map<SessionId, Promise<unknown>>()
   /**
    * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not
    * its id: a disposed fiber's session can be replaced by a different live

+ 21 - 21
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -85,7 +85,7 @@ async function liveSessionInFiber(
 ): Promise<Session> {
   let session!: Session
   await ctx.plugin(Object.assign((inner: Context) => {
-    session = inner.sessions.create(id, cwd !== undefined ? { meta: { cwd } } : undefined)
+    session = inner.sessions.create(SessionId(id), cwd !== undefined ? { meta: { cwd } } : undefined)
   }, { inject: ['sessions'] }))
   return session
 }
@@ -110,7 +110,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)
       try {
-        const session = ctx.sessions.create('live', { meta: { cwd: WORK } })
+        const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
         send(session, oneTurnLog())
         await ctx.parallel('session/flush', session)
 
@@ -127,7 +127,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)
       try {
-        const session = ctx.sessions.create('mutate', { meta: { cwd: WORK } })
+        const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
         const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
         // Mutate the live event object AFTER it was buffered by session/event.
         ;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
@@ -177,7 +177,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       try {
         const seed = oneTurnLog()
         // A fork: a brand-new id whose seed came from elsewhere.
-        const forked = ctx.sessions.create('forked', { seed, meta: { cwd: WORK } })
+        const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
         await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed
         const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
         expect(loaded.events).toEqual(seed)
@@ -196,7 +196,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const first = await freshCtx(fix)
       try {
         // First lifecycle: persist a session through the store.
-        const s1 = first.ctx.sessions.create('resumed', { meta: { cwd: WORK } })
+        const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
         send(s1, oneTurnLog())
         await first.ctx.parallel('session/flush', s1)
       } finally {
@@ -209,7 +209,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const second = await freshCtx(fix)
       try {
         const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
-        const s2 = second.ctx.sessions.create('resumed', { seed: loaded.events, meta: { cwd: WORK } })
+        const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
         await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt
         s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
         s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
@@ -231,7 +231,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const ctx = new Context()
       await ctx.plugin(SessionStore)
       // A session exists BEFORE the persistence plugin is applied.
-      const session = ctx.sessions.create('pre-existing', { meta: { cwd: WORK } })
+      const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
       session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
       session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
 
@@ -371,7 +371,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const fix = await makeFixture()
       const first = await freshCtx(fix)
       try {
-        const s1 = first.ctx.sessions.create('collide', { meta: { cwd: WORK } })
+        const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
         send(s1, oneTurnLog())
         await first.ctx.parallel('session/flush', s1)
       } finally {
@@ -383,7 +383,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       // exists. The rejection surfaces via the init promise (flush awaits it).
       const second = await freshCtx(fix)
       try {
-        const s2 = second.ctx.sessions.create('collide', { meta: { cwd: WORK } })
+        const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
         s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
         await expect(inits(second.ctx.sessionPersistence).get(s2))
           .rejects.toThrow(/already has a persisted log|id collision/)
@@ -401,14 +401,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // never materialized. A new live session reusing the id must reclaim it.
         let firstSession!: Session
         const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
-          firstSession = inner.sessions.create('abandoned', { meta: { cwd: WORK } })
+          firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
         }, { inject: ['sessions'] }))
         await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state
         await firstFiber.dispose() // disposed before any append → never materialized
 
         let reuse!: Session
         await ctx.plugin(Object.assign((inner: Context) => {
-          reuse = inner.sessions.create('abandoned', { meta: { cwd: WORK } })
+          reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
         }, { inject: ['sessions'] }))
         await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined()
         reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -428,7 +428,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       try {
         let first!: Session
         const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
-          first = inner.sessions.create('buffered', { meta: { cwd: WORK } })
+          first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
         }, { inject: ['sessions'] }))
         await inits(ctx.sessionPersistence).get(first)
         // Append a turn but do NOT flush — events sit in the write-behind buffer.
@@ -438,7 +438,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
 
         let reuse!: Session
         await ctx.plugin(Object.assign((inner: Context) => {
-          reuse = inner.sessions.create('buffered', { meta: { cwd: WORK } })
+          reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
         }, { inject: ['sessions'] }))
         await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/)
       } finally {
@@ -451,7 +451,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)
       try {
-        const session = ctx.sessions.create('idem', { meta: { cwd: WORK } })
+        const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
         session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
         session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
         await ctx.parallel('session/flush', session)
@@ -476,7 +476,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         await ctx.sessionPersistence.create(meta('lazy-claim', WORK))
         // A live session with that id arrives and claims it (cursor 0 matches
         // trivially), persisting its seed.
-        const live = ctx.sessions.create('lazy-claim', { seed: oneTurnLog(), meta: { cwd: WORK } })
+        const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
         await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined()
         const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
         expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
@@ -500,7 +500,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // seq 0..cursor-1 events would otherwise be filtered as already-persisted.
         let fresh!: Session
         await ctx.plugin(Object.assign((inner: Context) => {
-          fresh = inner.sessions.create('preview', { meta: { cwd: WORK } })
+          fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } })
         }, { inject: ['sessions'] }))
         await expect(inits(ctx.sessionPersistence).get(fresh))
           .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/)
@@ -521,7 +521,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
 
         // A live session SEEDED with the loaded log PLUS a new turn claims the
         // ownerless state and persists only the suffix.
-        const cont = ctx.sessions.create('claim', { seed: [
+        const cont = ctx.sessions.create(SessionId('claim'), { seed: [
           ...events,
           { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
           { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
@@ -545,7 +545,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // A live session reusing the id but at cwd WORK must NOT claim it — the
         // cwd scope is the fence (without it, WORK events would append under the
         // OTHER header). Rejected as a collision.
-        const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } })
+        const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
         await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
       } finally {
         await fiber.dispose()
@@ -563,7 +563,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load'))
         // A live session whose SEED matches the loaded prefix but whose cwd is
         // WORK must still be rejected — the cwd guard runs before the seed check.
-        const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } })
+        const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } })
         await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
       } finally {
         await fiber.dispose()
@@ -579,7 +579,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         await ctx.sessionPersistence.create(meta('no-cwd-state'))
         // A live session reusing the id but WITH cwd WORK is a cwd mismatch
         // (undefined vs WORK) and must be rejected.
-        const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } })
+        const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } })
         await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
       } finally {
         await fiber.dispose()
@@ -703,7 +703,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         // Append directly to a live session and flush IMMEDIATELY, before the
         // async onCreated init has necessarily set state (exercises the
         // state-undefined cursor path).
-        const session = ctx.sessions.create('flush-nostate', { meta: { cwd: WORK } })
+        const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
         session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
         session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
         await ctx.parallel('session/flush', session)

+ 2 - 1
packages/support/invariants/src/index.ts

@@ -21,6 +21,7 @@
 
 import type { Context } from 'cordis'
 import { HarnessError } from '@deepseek-ai/dsh-llm'
+import type { CallId } from '@deepseek-ai/dsh-llm'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 
@@ -65,7 +66,7 @@ interface SessionTrace {
    * Tool-call ids issued in the OPEN step awaiting a result. Cleared at
    * `step/end` — a result must arrive in the same step as its call.
    */
-  pendingCalls: Set<string>
+  pendingCalls: Set<CallId>
 }
 
 /**

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

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { InvariantError } from '@deepseek-ai/dsh-invariants'
 
@@ -175,8 +175,8 @@ describe('session-log invariants', () => {
 
   it('tracks turns per session independently', async () => {
     const { ctx } = await setup({ freeze: false })
-    const a = ctx.sessions.create('a')
-    const b = ctx.sessions.create('b')
+    const a = ctx.sessions.create(SessionId('a'))
+    const b = ctx.sessions.create(SessionId('b'))
     a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     // b is a fresh session — its own turn/start must not see a's open turn.
     expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()

+ 2 - 2
packages/support/ui-stdio/src/index.ts

@@ -22,7 +22,7 @@ import { createInterface } from 'node:readline'
 import type { Readable, Writable } from 'node:stream'
 import type { Context } from 'cordis'
 import z from 'schemastery'
-import type {} from '@deepseek-ai/dsh-agent'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 
 export const name = 'ui-stdio'
 export const inject = ['agents']
@@ -69,7 +69,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   // Loader validation, so it must be self-contained rather than trusting the
   // cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
   const welcome = config.welcome ?? 'ready.'
-  const agentId = config.agent ?? 'main'
+  const agentId = AgentId(config.agent ?? 'main')
   const { input, output, exit } = runtime
 
   let inReasoning = false

+ 32 - 26
packages/ui/acp/src/index.ts

@@ -60,7 +60,10 @@ import {
   type StopReason,
 } from '@agentclientprotocol/sdk'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import { CallId } from '@deepseek-ai/dsh-llm'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
 import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
 // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
@@ -138,7 +141,7 @@ export const Config: Schema<AcpConfig> = Schema.object({
  * map keyed by id (RFC 011 multi-session).
  */
 interface SessionRecord {
-  sessionId: string
+  sessionId: SessionId
   agent: Agent
   /**
    * The owned-agent disposer (from the {@link AgentHandle} the factory returned).
@@ -229,12 +232,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
   // reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
   // The two stay in lockstep: a record is added to `sessions` and the agent to
   // `bySession` together, and removed together.
-  const sessions = new Map<string, SessionRecord>()
-  const bySession = new WeakMap<Agent, string>()
+  const sessions = new Map<SessionId, SessionRecord>()
+  const bySession = new WeakMap<Agent, SessionId>()
   // Session ids whose `session/load` is mid-`resume()` (the slot is reserved
   // before the async resume so a pipelined load/new for the SAME id can't create
   // two agents). Distinct ids load concurrently; a given id loads once at a time.
-  const loadingIds = new Set<string>()
+  const loadingIds = new Set<SessionId>()
   // Set once the bridge has torn down (disposal or client disconnect). An async
   // `session/load` mid-`resume()` when teardown ran must observe this after its
   // await and NOT install a record (which would resurrect a live agent/listeners
@@ -265,7 +268,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
   }
 
   /** Resolve the live record for a sessionId, or throw an ACP error. */
-  const requireSession = (sessionId: string): SessionRecord => {
+  const requireSession = (sessionId: SessionId): SessionRecord => {
     const rec = sessions.get(sessionId)
     if (rec === undefined) {
       throw invalidParams(`unknown session: ${sessionId}`)
@@ -446,9 +449,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
         assertOpen()
         validateWorkspaceParams(params)
         validateMcpServers(params)
-        const sessionId = randomUUID()
+        const sessionId = SessionId(randomUUID())
         const handle = agents.create({
-          agentId: sessionId,
+          agentId: AgentId(sessionId),
           sessionId,
           meta: { cwd: params.cwd },
           agentOptions: agentOptions(config),
@@ -467,8 +470,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
 
       async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
         assertOpen()
-        if (sessions.has(params.sessionId) || loadingIds.has(params.sessionId)) {
-          throw invalidParams(`session ${params.sessionId} is already loaded`)
+        // The wire `params.sessionId` is a raw protocol string; brand it once at
+        // this entry so the session collections and the resume factory see a SessionId.
+        const sessionId = SessionId(params.sessionId)
+        if (sessions.has(sessionId) || loadingIds.has(sessionId)) {
+          throw invalidParams(`session ${sessionId} is already loaded`)
         }
         validateWorkspaceParams(params)
         validateMcpServers(params)
@@ -477,7 +483,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
         // resume() is pending, then both install a record and leak a second
         // agent. (Distinct ids load concurrently — the set is keyed by id.) The
         // slot is released in `finally` so a rejected load never wedges the id.
-        loadingIds.add(params.sessionId)
+        loadingIds.add(sessionId)
         try {
           // Validate the PERSISTED cwd BEFORE resuming — `list()` is a
           // metadata-only read (no full-log parse), so this rejects a session we
@@ -491,21 +497,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
           // always has a cwd (session/new requires it); reject the rest loudly.
           // (An id unknown to `list()` falls through to resume, which rejects with
           // the backend's not-found error.)
-          const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
+          const meta = (await sessionPersistence.list()).find(m => m.id === sessionId)
           if (meta !== undefined) {
             const persistedCwd = meta.cwd
             if (persistedCwd === undefined || !isAbsolute(persistedCwd)) {
               throw invalidParams(
-                `session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
+                `session ${sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
               )
             }
             if (!sameWorkspaceCwd(persistedCwd, params.cwd)) {
-              throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
+              throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
             }
           }
           const handle = await agents.resume({
-            agentId: params.sessionId,
-            resumeSessionId: params.sessionId,
+            agentId: AgentId(sessionId),
+            resumeSessionId: sessionId,
             agentOptions: agentOptions(config),
           })
           // The bridge may have torn down (disposal / client disconnect) while
@@ -523,20 +529,20 @@ export function apply(ctx: Context, config: AcpConfig): void {
             throw invalidParams('connection closed during session/load')
           }
           const agent = handle.agent
-          bySession.set(agent, params.sessionId)
+          bySession.set(agent, sessionId)
           // Snapshot the terminal capability ONCE for this session (used by both
           // the replay below and the post-load live stream) so a later
           // `initialize` can't desync the call/result of a tool card.
           const terminalEnabled = terminalOutputCap
           const record: SessionRecord = {
-            sessionId: params.sessionId,
+            sessionId,
             agent,
             dispose: () => handle.dispose(),
             presenter: makePresenter(),
             terminalEnabled,
             inflight: undefined,
           }
-          sessions.set(params.sessionId, record)
+          sessions.set(sessionId, record)
           // Replay the persisted event log to the client as session/update. Use
           // the raw event log (NOT deriveMessages, which drops assistant/chunk
           // and trace events): RFC 010's load contract reconstructs the streamed
@@ -556,17 +562,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
             cwd: agent.session.header.cwd,
           }
           for (const event of agent.session.events) {
-            streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal)
+            streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
           }
           return {}
         } finally {
-          loadingIds.delete(params.sessionId)
+          loadingIds.delete(sessionId)
         }
       },
 
       async prompt(params: PromptRequest): Promise<PromptResponse> {
         assertOpen()
-        const rec = requireSession(params.sessionId)
+        const rec = requireSession(SessionId(params.sessionId))
         if (rec.inflight !== undefined) {
           throw invalidParams('a prompt is already in flight for this session')
         }
@@ -595,7 +601,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
       },
 
       cancel(params: CancelNotification): Promise<void> {
-        const rec = sessions.get(params.sessionId)
+        const rec = sessions.get(SessionId(params.sessionId))
         if (rec === undefined) return Promise.resolve()
         // session/cancel maps to the queue-aware agent.cancel(reason): it aborts
         // a RUNNING step, clears the queued + steering FIFOs, and drops a
@@ -773,7 +779,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
  * no client update.
  */
 export function streamSessionEventUpdate(
-  sessionId: string,
+  sessionId: SessionId,
   event: SessionEvent,
   notify: (notification: SessionNotification) => void,
   presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
@@ -938,7 +944,7 @@ interface ResolvedResultPresentation {
  * stale entry's only cost is one map slot until the session ends.
  */
 export class ToolPresenter {
-  private readonly pending = new Map<string, { name: string; args: unknown; isTerminal: boolean }>()
+  private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>()
 
   /**
    * @param tools the registry to resolve tool definitions by name.
@@ -954,7 +960,7 @@ export class ToolPresenter {
   ) {}
 
   /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
-  call(callId: string, name: string, argsJson: string): ResolvedCallPresentation {
+  call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation {
     const args = parseToolArguments(argsJson)
     let present: ToolCallPresentation | undefined
     try {
@@ -986,7 +992,7 @@ export class ToolPresenter {
   }
 
   /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
-  result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
+  result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
     const call = this.pending.get(callId)
     this.pending.delete(callId)
     // No remembered call (unknown/late callId) → nothing to present from; raw content.

+ 5 - 4
packages/ui/acp/tests/bridge.spec.ts

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
 
 /**
@@ -61,8 +62,8 @@ describe('acp bridge', () => {
     expect(b.sessionId).toBeTruthy()
     expect(a.sessionId).not.toBe(b.sessionId)
     // Both agents are live and independently registered.
-    expect(harness.ctx.agents.get(a.sessionId)).toBeDefined()
-    expect(harness.ctx.agents.get(b.sessionId)).toBeDefined()
+    expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined()
+    expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined()
   })
 
   it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
@@ -77,7 +78,7 @@ describe('acp bridge', () => {
     const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
     expect(res.sessionId).toBeTruthy()
     // The session header records that cwd, so its bash tools run there.
-    expect(harness.ctx.agents.get(res.sessionId)!.session.header.cwd).toBe('/tmp')
+    expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp')
   })
 
   it('rejects non-empty additionalDirectories', async () => {
@@ -117,7 +118,7 @@ describe('acp bridge', () => {
       ],
     })
     expect(result.stopReason).toBe('end_turn')
-    const user = harness.ctx.agents.get(sessionId)!.session.events.find(event => event.type === 'user/message')
+    const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message')
     expect(JSON.stringify(user)).toContain('resource_link')
   })
 

+ 28 - 27
packages/ui/acp/tests/dispose.spec.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
 import { SessionId } from '@deepseek-ai/dsh-session'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { makeBridgeHarness, textResponse } from './harness.ts'
 
 describe('acp bridge — disposal & HMR safety', () => {
@@ -16,7 +17,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
 
     // Start a prompt that hangs in the model stream.
     const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -61,10 +62,10 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: [] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    expect(harness.ctx.agents.get(sessionId)).toBeDefined()
+    expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined()
 
     await harness.acpFiber.dispose() // tear down ONLY the bridge
-    expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
     await harness.dispose()
   })
 
@@ -91,7 +92,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
     // Start a prompt that hangs in the model stream. The prompt RPC will never
     // return (its transport is severed), so do not await it.
     void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
@@ -114,8 +115,8 @@ describe('acp bridge — disposal & HMR safety', () => {
     // and its session removed from the store, not merely idled (the old
     // behavior). The services live on the root ctx, so they survive this.
     await harness.acpFiber.dispose()
-    expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
-    expect(harness.ctx.sessions.get(sessionId)).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
+    expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
     await harness.dispose()
   })
 
@@ -127,7 +128,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
     void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
     await new Promise(r => setTimeout(r, 30))
     expect(agent.status).toBe('running')
@@ -144,7 +145,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: [] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const session = harness.ctx.agents.get(sessionId)!.session
+    const session = harness.ctx.agents.get(AgentId(sessionId))!.session
 
     await harness.ctx.fiber.dispose()
     const before = harness.updates.length
@@ -168,12 +169,12 @@ describe('acp bridge — disposal & HMR safety', () => {
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
     await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
-    const liveEvents = harness.ctx.agents.get(sessionId)!.session.events.length
+    const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length
     expect(liveEvents).toBeGreaterThan(0)
 
     // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
     await harness.acpFiber.dispose()
-    expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
 
     // Re-load the session from disk: every live event (incl. the closing
     // turn/end) was flushed before the session was detached.
@@ -200,7 +201,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
     void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
     await new Promise(r => setTimeout(r, 30))
     expect(agent.status).toBe('running')
@@ -210,7 +211,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
     // teardown (the composite effect runs its disposer chain as a unit).
     await harness.acpFiber.dispose()
-    expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
 
     // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
     // self-report) — NOT a crash-recovery `interrupted` substitute.
@@ -229,22 +230,22 @@ describe('acp bridge — disposal & HMR safety', () => {
     // queryable, with its session still in the store.
     const harness = await makeBridgeHarness({ storageDir, script: [] })
     const handleA = harness.ctx.agents.create({
-      agentId: 'sib-a', sessionId: 'sib-a', agentOptions: { model: 'mock' },
+      agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
     })
     const handleB = harness.ctx.agents.create({
-      agentId: 'sib-b', sessionId: 'sib-b', agentOptions: { model: 'mock' },
+      agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
     })
-    expect(harness.ctx.agents.get('sib-a')).toBe(handleA.agent)
-    expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent)
+    expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
+    expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
 
     await handleA.dispose()
     // A is gone — unregistered AND its session removed from the store.
-    expect(harness.ctx.agents.get('sib-a')).toBeUndefined()
-    expect(harness.ctx.sessions.get('sib-a')).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined()
+    expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
     expect(handleA.agent.status).toBe('disposed')
     // B is wholly unaffected.
-    expect(harness.ctx.agents.get('sib-b')).toBe(handleB.agent)
-    expect(harness.ctx.sessions.get('sib-b')).toBeDefined()
+    expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
+    expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
     expect(handleB.agent.status).not.toBe('disposed')
     await harness.dispose()
   })
@@ -261,16 +262,16 @@ describe('acp bridge — disposal & HMR safety', () => {
     const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
     harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
     const handle = harness.ctx.agents.create({
-      agentId: 'guard-a', sessionId: 'guard-a', agentOptions: { model: 'mock' },
+      agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
     })
     handle.agent.send([{ type: 'text', text: 'go' }])
     await handle.agent.whenIdle()
-    expect(harness.ctx.sessions.get('guard-a')).toBeDefined()
+    expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
 
     // Dispose: the throwing listener must NOT break the chain before detach.
     await handle.dispose()
-    expect(harness.ctx.agents.get('guard-a')).toBeUndefined()
-    expect(harness.ctx.sessions.get('guard-a')).toBeUndefined() // detach still ran
+    expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined()
+    expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
     await harness.dispose()
   })
 
@@ -282,7 +283,7 @@ describe('acp bridge — disposal & HMR safety', () => {
     // observe the same quiescence boundary.
     const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
     const handle = harness.ctx.agents.create({
-      agentId: 'conc-a', sessionId: 'conc-a', agentOptions: { model: 'mock' },
+      agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
     })
     // Drive a turn that hangs in the model stream, so the loop is mid-turn when
     // disposed — its exit runs a final session/flush we can gate to hold the
@@ -312,8 +313,8 @@ describe('acp bridge — disposal & HMR safety', () => {
     // Release the flush; both resolve together and the session is gone.
     releaseFlush()
     await Promise.all([first, second])
-    expect(harness.ctx.agents.get('conc-a')).toBeUndefined()
-    expect(harness.ctx.sessions.get('conc-a')).toBeUndefined()
+    expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined()
+    expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
     await harness.dispose()
   })
 })

+ 3 - 1
packages/ui/acp/tests/edges.spec.ts

@@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
+import { AgentId } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
 import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
 
 describe('acp bridge — demux & config edges', () => {
@@ -25,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
     await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
     const before = harness.updates.length
 
-    const { agent: foreign } = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } })
+    const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
     foreign.send([{ type: 'text', text: 'hi' }])
     await foreign.whenIdle()
     await new Promise(r => setTimeout(r, 10))

+ 5 - 4
packages/ui/acp/tests/load.spec.ts

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
 import { SessionId } from '@deepseek-ai/dsh-session'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
 
 /** Concatenate the text of all agent_message_chunk updates. */
@@ -155,7 +156,7 @@ describe('acp bridge — session/load replay', () => {
     release()                            // resume() finishes AFTER teardown
     expect(await loadResult).toBe('rejected')
     // No live agent was installed for the closed connection.
-    expect(loader.ctx.agents.get(sessionId)).toBeUndefined()
+    expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
   })
 
   it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
@@ -176,11 +177,11 @@ describe('acp bridge — session/load replay', () => {
     await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
       .rejects.toThrow(/cwd mismatch/)
-    expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
+    expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined()
 
     const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
     expect(res).toBeDefined()
-    expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd)
+    expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
   })
 
   it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
@@ -215,7 +216,7 @@ describe('acp bridge — session/load replay', () => {
     // Rejected BEFORE resume (metadata-only check) — no agent was registered, so
     // the id is not wedged: a later attempt hits the same clean rejection, not a
     // duplicate-registration error.
-    expect(loader.ctx.agents.get('legacy')).toBeUndefined()
+    expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined()
     await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
       .rejects.toThrow(/no absolute persisted cwd/)
   })

+ 3 - 2
packages/ui/acp/tests/multi-session.spec.ts

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
 
 /** Text of the agent_message_chunk updates scoped to one session id. */
@@ -101,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => {
     await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
     const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
     const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
-    const agentA = harness.ctx.agents.get(a)!
-    const agentB = harness.ctx.agents.get(b)!
+    const agentA = harness.ctx.agents.get(AgentId(a))!
+    const agentB = harness.ctx.agents.get(AgentId(b))!
 
     // Wait deterministically for BOTH agents to enter `running` (not a fixed
     // sleep — agent startup latency is unbounded on a loaded worker).

+ 2 - 2
packages/ui/acp/tests/properties.spec.ts

@@ -17,7 +17,7 @@
 import { describe, expect, it } from 'vitest'
 import fc from 'fast-check'
 import { CallId } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionNotification } from '@agentclientprotocol/sdk'
 import { streamSessionEventUpdate } from '../src/index.ts'
 
@@ -85,7 +85,7 @@ function actionsToEvents(actions: Action[]): SessionEvent[] {
 
 function runStream(events: SessionEvent[]): SessionNotification['update'][] {
   const out: SessionNotification['update'][] = []
-  for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update))
+  for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
   return out
 }
 

+ 5 - 5
packages/ui/acp/tests/stream-update.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { CallId } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionNotification } from '@agentclientprotocol/sdk'
 import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
 import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
@@ -8,14 +8,14 @@ import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/in
 /** Collect the updates a single event produces (no presenter → generic fallback). */
 function updatesFor(event: SessionEvent): SessionNotification['update'][] {
   const out: SessionNotification['update'][] = []
-  streamSessionEventUpdate('s1', event, n => out.push(n.update))
+  streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
   return out
 }
 
 /** Collect the updates emitted by the live prompt stream (user echo suppressed). */
 function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
   const out: SessionNotification['update'][] = []
-  streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
+  streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
   return out
 }
 
@@ -138,7 +138,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
 
   function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
     const out: SessionNotification['update'][] = []
-    for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter)
+    for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
     return out
   }
 
@@ -324,7 +324,7 @@ describe('terminal-card mapping (capability-gated)', () => {
   function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
     const presenter = new ToolPresenter(registryOf(tool))
     const out: SessionNotification['update'][] = []
-    for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
+    for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd })
     return out
   }
 

+ 3 - 2
packages/ui/acp/tests/turns.spec.ts

@@ -3,6 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { defineTool } from '@deepseek-ai/dsh-tools'
+import { AgentId } from '@deepseek-ai/dsh-agent'
 import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
 import {
   errorResponse,
@@ -283,7 +284,7 @@ describe('acp bridge — turn outcomes', () => {
     // OWN turn with the real model answer.
     harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
     const sessionId = await newSession(harness)
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
     // On the queued prompt, synchronously inject a one-shot context turn (idle
     // inject writes turn/start{injection} → context/message → turn/end). Fire
     // once so it lands between install and the prompt turn.
@@ -339,7 +340,7 @@ describe('acp bridge — turn outcomes', () => {
     await harness.client.cancel({ sessionId })
     const res = await promptDone
     expect(res.stopReason).toBe('cancelled')
-    const agent = harness.ctx.agents.get(sessionId)!
+    const agent = harness.ctx.agents.get(AgentId(sessionId))!
     await agent.whenIdle()
     // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so
     // no second turn was batched or leaked. (A best-effort abort that left queued

+ 26 - 0
packages/util/brand/README.md

@@ -0,0 +1,26 @@
+# dsh-brand
+
+The `Branded<B>` nominal-typing primitive — a tiny, **type-only** package (no runtime code, no harness-package dependency) shared by every package that owns a cross-boundary id.
+
+## What `Branded` is
+
+A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime.
+
+```ts
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+export type SessionId = Branded<'SessionId'>
+
+/** Brand a string as a SessionId (a plain cast — zero runtime cost). */
+export function SessionId(id: string): SessionId {
+  return id as SessionId
+}
+```
+
+Construction goes through the per-id factory in the OWNING package (a plain cast inside — zero runtime cost). Comparison, logging, JSON serialization, and the wire format all behave exactly as for an ordinary string; the brand is erased at compile time.
+
+## Policy: brand ids that cross package boundaries
+
+A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.**
+
+This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`.

+ 28 - 0
packages/util/brand/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "@deepseek-ai/dsh-brand",
+  "description": "Type-only Branded<B> nominal-typing primitive for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 27 - 0
packages/util/brand/src/index.ts

@@ -0,0 +1,27 @@
+/**
+ * The `Branded<B>` nominal-typing primitive — a type-only utility (no runtime
+ * code, no harness-package dependency) shared by every package that owns a
+ * cross-boundary id.
+ *
+ * A brand makes structurally-identical strings non-interchangeable at the type
+ * level: an `AgentId` cannot be passed where a `CallId` is expected, even
+ * though both are plain strings at runtime. Construction goes through a per-id
+ * factory in the OWNING package (a plain cast inside — zero runtime cost);
+ * comparison, logging, and serialization all behave as ordinary strings.
+ *
+ * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call
+ * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent,
+ * `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package
+ * boundaries and could plausibly be confused; not every string needs a brand.
+ * This package owns ONLY the primitive — no concrete id, no runtime code beyond
+ * the (erased) type — so the brand vocabulary stays dependency-free and a
+ * package can brand its ids without depending on an unrelated capability
+ * package (e.g. dsh-bash brands its ids without pulling in dsh-llm).
+ *
+ * @module @deepseek-ai/dsh-brand
+ */
+
+declare const BRAND: unique symbol
+
+/** A string carrying a compile-time-only brand `B`. */
+export type Branded<B extends string> = string & { readonly [BRAND]: B }

+ 11 - 0
packages/util/brand/tsconfig.json

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

+ 18 - 0
pnpm-lock.yaml

@@ -68,6 +68,9 @@ importers:
 
   packages/bash/bash:
     devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -117,6 +120,9 @@ importers:
 
   packages/core/agent:
     devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -163,6 +169,9 @@ importers:
 
   packages/core/session:
     devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -196,6 +205,9 @@ importers:
 
   packages/llm/llm:
     devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       cordis:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -365,6 +377,12 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/util/brand:
+    devDependencies:
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   vendor/cordis:
     dependencies:
       '@cordisjs/plugin-include':

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

@@ -1,7 +1,7 @@
 {
   "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.",
   "entries": [
-    { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/llm/llm/src/brand.ts" },
+    { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
     { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
     { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" },
     { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" },

+ 1 - 0
tsconfig.base.json

@@ -45,6 +45,7 @@
         "./packages/bash/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
+        "./packages/util/*/src",
         "./packages/support/*/src"
       ]
     }

+ 1 - 0
tsconfig.build.json

@@ -10,6 +10,7 @@
     { "path": "./vendor/timer" },
     { "path": "./vendor/hmr" },
     { "path": "./vendor/logger-console" },
+    { "path": "./packages/util/brand" },
     { "path": "./packages/llm/llm" },
     { "path": "./packages/core/session" },
     { "path": "./packages/session-persistence/session-persistence" },

+ 1 - 0
tsconfig.typecheck.json

@@ -22,6 +22,7 @@
         "./packages/bash/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
+        "./packages/util/*/src",
         "./packages/support/*/src"
       ]
     }