Bläddra i källkod

refactor: hide the concrete agent loop

Tianyi Cui 2 månader sedan
förälder
incheckning
225796c90d
40 ändrade filer med 234 tillägg och 217 borttagningar
  1. 1 1
      docs/architecture.md
  2. 1 1
      docs/config-catalog.md
  3. 13 13
      docs/cordis-catalog/events.md
  4. 5 3
      docs/cordis-catalog/services.md
  5. 1 1
      docs/core-data-structures/core.md
  6. 13 13
      docs/event-producer-consumer.md
  7. 1 1
      docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md
  8. 3 3
      examples/coding-agent/tests/code-mode.e2e.ts
  9. 3 3
      examples/coding-agent/tests/harness.ts
  10. 2 3
      examples/coding-agent/tests/resume.e2e.ts
  11. 3 3
      examples/cordis-agent/tests/harness.ts
  12. 4 4
      packages/bash/tool-bash/tests/integration.spec.ts
  13. 3 3
      packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
  14. 2 2
      packages/cordis/tool-cordis/src/api-catalog.ts
  15. 3 3
      packages/cordis/tool-cordis/tests/integration.spec.ts
  16. 1 1
      packages/core/README.md
  17. 4 6
      packages/core/agent-loop/README.md
  18. 3 4
      packages/core/agent-loop/src/index.ts
  19. 23 19
      packages/core/agent-loop/tests/agent.spec.ts
  20. 13 9
      packages/core/agent-loop/tests/cancel.spec.ts
  21. 8 8
      packages/core/agent-loop/tests/config-session-id.spec.ts
  22. 10 6
      packages/core/agent-loop/tests/coverage-edges.spec.ts
  23. 5 5
      packages/core/agent-loop/tests/interception.spec.ts
  24. 13 9
      packages/core/agent-loop/tests/loop.spec.ts
  25. 6 6
      packages/core/agent-loop/tests/properties.spec.ts
  26. 5 5
      packages/core/agent-loop/tests/request-reconstruction.spec.ts
  27. 12 12
      packages/core/agent-loop/tests/resume.spec.ts
  28. 30 26
      packages/core/agent-loop/tests/review-fixes.spec.ts
  29. 4 4
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  30. 3 3
      packages/core/agent-loop/tests/tool-order.spec.ts
  31. 3 3
      packages/core/agent-loop/tests/turn-stop.spec.ts
  32. 2 3
      packages/core/agent/src/types.ts
  33. 6 6
      packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
  34. 4 4
      packages/hooks/hooks-claude/tests/bridge.spec.ts
  35. 7 7
      packages/hooks/hooks-claude/tests/coverage.spec.ts
  36. 4 4
      packages/hooks/hooks-codex/tests/bridge.spec.ts
  37. 5 5
      packages/hooks/hooks-codex/tests/coverage.spec.ts
  38. 3 3
      packages/todo/tool-todo/tests/integration.spec.ts
  39. 1 1
      packages/ui/acp/README.md
  40. 1 1
      packages/ui/acp/src/index.ts

+ 1 - 1
docs/architecture.md

@@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
 | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
 | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
 | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events |
-| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver |
+| `ctx.agentLoop` | `dsh-agent-loop` | shipped concrete `Agent` driver |
 
 ### Capability Services
 

+ 1 - 1
docs/config-catalog.md

@@ -129,7 +129,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
 
-Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:323`](../packages/core/agent-loop/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-local`
 

+ 13 - 13
docs/cordis-catalog/events.md

@@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts)
 
 ### `agent/disposed` — emit
 
@@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts)
 
 ### `agent/error` — emit
 
@@ -47,7 +47,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:593`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:592`](../../packages/core/agent/src/types.ts)
 
 ### `agent/pre-step` — serial
 
@@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:426`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts)
 
 ### `agent/prompt-submit` — waterfall
 
@@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
 
 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:444`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts)
 
 ### `agent/queued` — emit
 
@@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and 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:348`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
 
 ### `agent/request` — waterfall
 
@@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
 
 Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:473`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts)
 
 ### `agent/session-prefix` — waterfall
 
@@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:525`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:524`](../../packages/core/agent/src/types.ts)
 
 ### `agent/session-start` — emit
 
@@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
 
 Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts)
 
 ### `agent/status` — emit
 
@@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts)
 
 ### `agent/step-result` — waterfall
 
@@ -149,7 +149,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:540`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts)
 
 ### `agent/turn-continuation` — waterfall
 
@@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:558`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:557`](../../packages/core/agent/src/types.ts)
 
 ### `agent/turn-stop` — serial
 
@@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:575`](../../packages/core/agent/src/types.ts)
 
 ## `approval/*`
 

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

@@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
 
 ## `ctx.agentLoop` — `AgentLoop`
 
-Concrete ReactLoopAgent factory and driver service.
+Concrete agent factory and driver service.
 
 ```ts cordis-catalog
-create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
+create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent
 async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
 async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts)
+Types: [Agent](../core-data-structures/core.md)
+
+Source: [`packages/core/agent-loop/src/index.ts:336`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 

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

@@ -248,7 +248,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`,
 
 ## The agent handle
 
-`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation.
+`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it.
 
 Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
 

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

@@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
-| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
-| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
-| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
-| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:576`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
+| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
+| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
+| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:524`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
+| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
+| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:557`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:575`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |

+ 1 - 1
docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md

@@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w
 
 The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee:
 
-- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
+- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
 - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves);
 - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.
 

+ 3 - 3
examples/coding-agent/tests/code-mode.e2e.ts

@@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -58,7 +58,7 @@ async function codeModeHarness(cwd: string): Promise<Context> {
   return harness
 }
 
-function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(harness: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = harness.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 3 - 3
examples/coding-agent/tests/harness.ts

@@ -4,8 +4,8 @@ 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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
@@ -70,7 +70,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
   return ctx
 }
 
-export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

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

@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
 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 { SessionId } from '@deepseek-ai/dsh-session'
 import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
 
@@ -41,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     const first = (await ctx.agents.create({
       sessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash' },
-    })).agent as ReactLoopAgent
+    })).agent
     first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
     await waitForIdle(ctx, first)
     await ctx.fiber.dispose()
@@ -54,7 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     const resumed = (await ctx.agents.resume({
       resumeSessionId: SESSION_ID,
       agentOptions: { model: 'deepseek-v4-flash' },
-    })).agent as ReactLoopAgent
+    })).agent
     expect(resumed.session.id).toBe(SESSION_ID)
     // The prior user turn is in the rehydrated log before the model is asked.
     expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)

+ 3 - 3
examples/cordis-agent/tests/harness.ts

@@ -3,8 +3,8 @@ import LlmService 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 AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
 
@@ -34,7 +34,7 @@ export async function cordisHarness(): Promise<Context> {
   return ctx
 }
 
-export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 4 - 4
packages/bash/tool-bash/tests/integration.spec.ts

@@ -5,9 +5,9 @@ import SessionStore, { 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop 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'
@@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function events(agent: ReactLoopAgent): SessionEvent[] {
+function events(agent: Agent): SessionEvent[] {
   return [...agent.session.events]
 }
 

+ 3 - 3
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -7,9 +7,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import { isToolPairingBalanced } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
 import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
@@ -105,7 +105,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
   return { ctx, compact }
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 2 - 2
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -54,9 +54,9 @@ export interface TypeApiEntry {
 export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'agentLoop',
-    summary: 'Concrete ReactLoopAgent factory and driver service.',
+    summary: 'Concrete agent factory and driver service.',
     methods: [
-      'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
+      'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): Agent',
       'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
       'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
     ],

+ 3 - 3
packages/cordis/tool-cordis/tests/integration.spec.ts

@@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as ToolCordis from '../src/index.ts'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import { REVERSE_TOOL_CODE } from './helpers.ts'
@@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 1 - 1
packages/core/README.md

@@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
 | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
 | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
 | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
-| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
+| `agent-loop/` | The concrete `Agent` plugin and loop driver | `ctx.agentLoop` |
 | `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
 
 `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.

+ 4 - 6
packages/core/agent-loop/README.md

@@ -1,6 +1,6 @@
 # dsh-agent-loop
 
-THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
+THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle.
 
 This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
 
@@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA
 
 Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
 
-- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-<uuid>` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity.
+- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-<uuid>` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity.
 
 `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
 
@@ -42,11 +42,9 @@ interface Config {
 
 Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
 
-### Exported concrete class
+### Internal concrete driver
 
-- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
-
-`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
+The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
 
 ### Loop lifecycle (`loop.ts`)
 

+ 3 - 4
packages/core/agent-loop/src/index.ts

@@ -12,6 +12,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
 import type { Scope } from '@deepseek-ai/dsh-scope'
 import { agentEvents } from '@deepseek-ai/dsh-agent'
 import type {
+  Agent,
   AgentFactory,
   AgentHandle,
   AgentOptions,
@@ -32,8 +33,6 @@ import {
 } from './agent.ts'
 import type { PreparedReactLoopAgent } from './agent.ts'
 
-export { ReactLoopAgent } from './agent.ts'
-
 /** Fiber states that cannot own or serve a new lifecycle. */
 const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
   FiberState.UNLOADING,
@@ -333,7 +332,7 @@ export interface Config {
   })[]
 }
 
-/** Concrete ReactLoopAgent factory and driver service. */
+/** Concrete agent factory and driver service. */
 export class AgentLoop extends Service implements AgentFactory {
   static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
 
@@ -389,7 +388,7 @@ export class AgentLoop extends Service implements AgentFactory {
    * @param meta - optional fresh-session workspace metadata.
    * @returns the published running agent.
    */
-  create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
+  create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
     const loopCtx = this.runtime.ctx
     const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
     try {

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

@@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
-import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
+function driverDone(agent: Agent): Promise<void> {
+  return (agent as Agent & { done: Promise<void> }).done
+}
+
 async function harness(adapter: MockAdapter) {
   const ctx = new Context()
   await ctx.plugin(LlmService)
@@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
+function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === expected) {
@@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
-describe('ReactLoopAgent', () => {
+describe('Agent', () => {
   it('rejects access before context binding and a second driver for one session', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
@@ -70,7 +74,7 @@ describe('ReactLoopAgent', () => {
     expect(agent.options).toBe(options)
     expect(agent.id).toBe('owned-bindings')
     expect(agent.session.id).toBe(agent.id)
-    expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
+    expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
 
     await ctx.fiber.dispose()
   })
@@ -78,14 +82,14 @@ describe('ReactLoopAgent', () => {
   it('send() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
   })
@@ -93,14 +97,14 @@ describe('ReactLoopAgent', () => {
   it('steer() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
   })
@@ -108,14 +112,14 @@ describe('ReactLoopAgent', () => {
   it('inject() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
   })
@@ -251,7 +255,7 @@ describe('ReactLoopAgent', () => {
   })
 
   it('disposer is idempotent (double-stop)', async () => {
-    // Create a bare ReactLoopAgent and start it through the package-internal
+    // Create a bare Agent and start it through the package-internal
     // test seam. Then call its disposer twice — the second call hits the
     // early-return branch.
     const ctx = new Context()
@@ -367,7 +371,7 @@ describe('ReactLoopAgent', () => {
   it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
     // Covers the waiter's disposed arm: whenIdle() queues an internal waiter
     // while running (not the fast path), then the disposer settles it and chains
-    // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
+    // `done` (loop exit), not an eager resolve. A bare Agent + direct
     // internal driver disposer keeps the emit synchronous.
     const ctx = new Context()
     await ctx.plugin(LlmService)
@@ -401,7 +405,7 @@ describe('ReactLoopAgent', () => {
     // it. Regression for the round-3 whenIdle finding.
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -422,7 +426,7 @@ describe('ReactLoopAgent', () => {
     // only after `done` — i.e. the loop has actually exited.
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -430,7 +434,7 @@ describe('ReactLoopAgent', () => {
     await new Promise(r => setTimeout(r, 30))
 
     let doneResolved = false
-    void agent.done.then(() => { doneResolved = true })
+    void driverDone(agent).then(() => { doneResolved = true })
     await fiber.dispose() // sets status disposed, aborts, drains the loop
     expect(agent.status).toBe('disposed')
 

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

@@ -16,11 +16,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
+function driverDone(agent: Agent): Promise<void> {
+  return (agent as Agent & { done: Promise<void> }).done
+}
+
 async function harness(adapter: MockAdapter) {
   const ctx = new Context()
   await ctx.plugin(LlmService)
@@ -33,12 +37,12 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
 /** Resolve on the agent's next idle transition (event-based, not status poll). */
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -47,7 +51,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
 }
 
 /** All user-message texts recorded in the log (to assert what actually ran). */
-function userTexts(agent: ReactLoopAgent): string[] {
+function userTexts(agent: Agent): string[] {
   return agent.session.events
     .filter(e => e.type === 'user/message')
     .flatMap(e => e.type === 'user/message' ? e.data.content : [])
@@ -207,7 +211,7 @@ describe('Agent.cancel()', () => {
       sessionId: SessionId('dispose-prefix-session'),
       agentOptions: { model: 'mock' },
     })
-    const agent = handle.agent as ReactLoopAgent
+    const agent = handle.agent
 
     let disposalDone: Promise<void> | undefined
     let streamed = false
@@ -220,7 +224,7 @@ describe('Agent.cancel()', () => {
     send(agent, 'go')
     await new Promise(resolve => setTimeout(resolve, 0))
     await disposalDone
-    await agent.done
+    await driverDone(agent)
 
     // No step opened, no model call ran, and the turn closed disposed.
     expect(streamed).toBe(false)
@@ -337,7 +341,7 @@ describe('Agent.cancel()', () => {
       sessionId: SessionId('dispose-step-start-session'),
       agentOptions: { model: 'mock' },
     })
-    const agent = handle.agent as ReactLoopAgent
+    const agent = handle.agent
 
     let disposalDone: Promise<void> | undefined
     let streamed = false
@@ -348,7 +352,7 @@ describe('Agent.cancel()', () => {
 
     send(agent, 'go')
     await disposalDone
-    await agent.done
+    await driverDone(agent)
 
     expect(streamed).toBe(false)
     expect(adapter.requests).toHaveLength(0)

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

@@ -7,16 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 const dirs: string[] = []
 afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -57,7 +57,7 @@ describe('config-driven session id', () => {
     await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
-    const a1 = ctx1.agents.list()[0] as ReactLoopAgent
+    const a1 = ctx1.agents.list()[0] as Agent
     expect(a1.id).toBe(a1.session.id)
     expect(a1.session.id).toMatch(idPattern)
     expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
@@ -76,7 +76,7 @@ describe('config-driven session id', () => {
     await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
     await ctx2.plugin(SessionPersistenceJsonl, { root })
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
-    const a2 = ctx2.agents.list()[0] as ReactLoopAgent
+    const a2 = ctx2.agents.list()[0] as Agent
     expect(a2.id).toBe(a2.session.id)
     expect(a2.session.id).toMatch(idPattern)
     expect(a2.session.id).not.toBe(a1.session.id)
@@ -100,7 +100,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 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
     a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -119,10 +119,10 @@ describe('config-driven session id', () => {
     ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
 
     // The deferred resume runs on a microtask after the backend is available.
-    let resumed: ReactLoopAgent | undefined
+    let resumed: Agent | undefined
     for (let i = 0; i < 50 && !resumed; i++) {
       await new Promise(r => setTimeout(r, 5))
-      resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined
+      resumed = ctx2.agents.get(SessionId('sticky-1'))
     }
     expect(resumed).toBeDefined()
     // The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),

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

@@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
 import type { SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
+function driverDone(agent: Agent): Promise<void> {
+  return (agent as Agent & { done: Promise<void> }).done
+}
+
 async function harness(adapter: MockAdapter) {
   const ctx = new Context()
   await ctx.plugin(LlmService)
@@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => {
   it('handles dispose during model streaming producing reason "disposed"', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => {
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose() // dispose during hang
-    await agent.done
+    await driverDone(agent)
 
     // The review-fixes test for 'HIGH: disposed status' already covers
     // this assertion path. The reason is 'disposed' because isDisposed() is

+ 5 - 5
packages/core/agent-loop/tests/interception.spec.ts

@@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
-import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
 /**
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
-function events(agent: ReactLoopAgent): SessionEvent[] {
+function events(agent: Agent): SessionEvent[] {
   return [...agent.session.events]
 }
 

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

@@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
 
+function driverDone(agent: Agent): Promise<void> {
+  return (agent as Agent & { done: Promise<void> }).done
+}
+
 async function harness(adapter: MockAdapter, persona = '') {
   const ctx = new Context()
   await ctx.plugin(LlmService)
@@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') {
  * invoke this right after send(), when the loop hasn't woken yet (status is
  * still 'idle' synchronously), so polling the current status would lie.
  */
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -175,7 +179,7 @@ describe('agent loop', () => {
       agentOptions: { model: 'mock' },
     })
 
-    const agent = handle.agent as ReactLoopAgent
+    const agent = handle.agent
     send(agent, 'hi')
     await waitForIdle(ctx, agent)
 
@@ -911,7 +915,7 @@ describe('agent loop', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -922,7 +926,7 @@ describe('agent loop', () => {
     expect(agent.status).toBe('running')
 
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     expect(agent.status).toBe('disposed')
     expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
@@ -942,7 +946,7 @@ describe('agent loop', () => {
     })
     ctx.llm.registerAdapter(['mock'], adapter)
 
-    const agent = ctx.agents.list()[0]! as ReactLoopAgent
+    const agent = ctx.agents.list()[0]!
     expect(agent).toBeDefined()
     expect(agent.id).toBe(agent.session.id)
     expect(agent.id).toMatch(/^config-agent-session-/)
@@ -965,7 +969,7 @@ describe('agent loop', () => {
       agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }],
     })
 
-    const agent = ctx.agents.list()[0]! as ReactLoopAgent
+    const agent = ctx.agents.list()[0]!
     expect(agent.session.header.cwd).toBe('/work/project')
   })
 

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

@@ -17,9 +17,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import fc from 'fast-check'
 
 /** A never-exhausting adapter: every model call returns the same short reply. */
@@ -48,7 +48,7 @@ async function harness() {
 }
 
 /** Resolve on the agent's next transition to idle (event-based, not polled). */
-function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function nextIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
 
 /** Record every status transition for the legal-machine assertion. Returns
  * the seen list plus a disposer for the listener (per the registry convention). */
-function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
+function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
   const seen: string[] = []
   const dispose = ctx.on('agent/status', (subject, status) => {
     if (subject === agent) seen.push(status)
@@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di
   return { seen, dispose }
 }
 
-function userMessageTexts(agent: ReactLoopAgent): string[] {
+function userMessageTexts(agent: Agent): string[] {
   return agent.session.events
     .filter(e => e.type === 'user/message')
     .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
 }
 
-function turnNumbers(agent: ReactLoopAgent): number[] {
+function turnNumbers(agent: Agent): number[] {
   return agent.session.events
     .filter(e => e.type === 'turn/start')
     .map(e => (e.data as { turn: number }).turn)

+ 5 - 5
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -15,9 +15,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
 import SessionStore, { Session, SessionId, foldRequestHeader } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
 async function harness(adapter: MockAdapter, persona = 'stable base') {
@@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -229,7 +229,7 @@ describe('request stability across the loop', () => {
       seed: [...agent.session.events],
       agentOptions: { model: 'mock' },
     })
-    const agent2 = handle.agent as ReactLoopAgent
+    const agent2 = handle.agent
     send(agent2, 'second')
     await waitForIdle(ctx2, agent2)
 

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

@@ -8,10 +8,10 @@ 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
 import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 const dirs: string[] = []
@@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
   return root
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -124,7 +124,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 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
@@ -140,7 +140,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({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
     expect(a2.session.header.cwd).toBeUndefined()
     await ctx2.fiber.dispose()
   })
@@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const sources1: string[] = []
     ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
-    const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
     expect(sources1).toEqual(['startup'])
     a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
@@ -443,7 +443,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({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
     expect(a2.session.header.parentSession).toBe('parent-sess')
     expect(a2.session.header.cwd).toBe('/w')
     expect(a2.session.header.seedLength).toBe(seed.length)
@@ -457,7 +457,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 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
     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' } })
@@ -482,7 +482,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 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
     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' } })
@@ -500,7 +500,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({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
     const flat = JSON.stringify(a2.session.deriveMessages())
     expect(flat).toContain('background task 42 finished')
     await ctx2.fiber.dispose()
@@ -510,7 +510,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 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
+    const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
     a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     const events1 = [...a1.session.events]
@@ -530,7 +530,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({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
+    const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
     // The resumed session carries the prior history…
     expect(a2.session.id).toBe('sess-resume')
     expect(a2.session.events.length).toBe(events1.length)

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

@@ -4,13 +4,17 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
 import SessionStore, { Session, SessionEvent, 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, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { prepareReactLoopAgent } from '../src/agent.ts'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
+function driverDone(agent: Agent): Promise<void> {
+  return (agent as Agent & { done: Promise<void> }).done
+}
+
 /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
 
 async function harness(adapter: MockAdapter) {
@@ -25,7 +29,7 @@ async function harness(adapter: MockAdapter) {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -36,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function send(agent: ReactLoopAgent, text: string) {
+function send(agent: Agent, text: string) {
   agent.send([{ type: 'text', text }])
 }
 
@@ -324,7 +328,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -337,7 +341,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     expect(statuses).toEqual(['running', 'disposed'])
     expect(reasons).toEqual([{ kind: 'disposed' }])
@@ -347,7 +351,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -359,7 +363,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose()
-    await agent.done // must not hang
+    await driverDone(agent) // must not hang
 
     expect(agent.status).toBe('disposed')
     expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
@@ -697,7 +701,7 @@ describe('turn and step boundary recovery', () => {
   }
 
   /** Count turn/step boundary events for balance assertions. */
-  function boundaryCounts(agent: ReactLoopAgent) {
+  function boundaryCounts(agent: Agent) {
     const e = [...agent.session.events]
     return {
       turnStart: e.filter(x => x.type === 'turn/start').length,
@@ -871,7 +875,7 @@ describe('turn and step boundary recovery', () => {
     // balanced with reason disposed (no error event for a disposal).
     const adapter = new MockAdapter(['hang'])
     const ctx = await balancedHarness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -882,7 +886,7 @@ describe('turn and step boundary recovery', () => {
     send(agent, 'go')
     await new Promise(r => setTimeout(r, 30))
     await fiber.dispose() // dispose during the hanging step
-    await agent.done
+    await driverDone(agent)
 
     const e = [...agent.session.events]
     const turnStarts = e.filter(x => x.type === 'turn/start').length
@@ -900,7 +904,7 @@ describe('turn and step boundary recovery', () => {
     // and must preserve reason=disposed rather than rewrite it as a plugin error.
     const adapter = new MockAdapter([textResponse('never reached')])
     const ctx = await balancedHarness(adapter)
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -919,7 +923,7 @@ describe('turn and step boundary recovery', () => {
     ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
 
     send(agent, 'go')
-    await agent.done
+    await driverDone(agent)
 
     const e = [...agent.session.events]
     // Balanced: one turn/start, one turn/end carrying disposed (NOT error).
@@ -1148,7 +1152,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
     // calls stop() synchronously, setting status=disposed), then release the
     // block. The loop must check isDisposed() after assembly and end the turn
     // `disposed` — no LLM call. Don't await fiber.dispose() before releasing
-    // the blocker: the dispose chain awaits agent.done, which hangs until the
+    // the blocker: the dispose chain awaits driverDone(agent), which hangs until the
     // loop unblocks.
     const adapter = new MockAdapter(['hang'])
     let releaseAssemble!: () => void
@@ -1170,7 +1174,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
       return next()
     })
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -1183,15 +1187,15 @@ describe('disposal and cancellation during pre-step assembly', () => {
     await new Promise(r => setTimeout(r, 50))
 
     // Start disposal — stop() sets status=disposed synchronously, then the
-    // disposer's await agent.done hangs because the loop is blocked in the
+    // disposer's await driverDone(agent) hangs because the loop is blocked in the
     // waterfall. Do NOT await yet; release the blocker first.
     const disposalDone = fiber.dispose()
 
     // Now release the blocked waterfall — the loop unblocks, checks
-    // isDisposed(), and exits, which resolves agent.done and disposalDone.
+    // isDisposed(), and exits, which resolves driverDone(agent) and disposalDone.
     releaseAssemble()
     await disposalDone
-    await agent.done
+    await driverDone(agent)
     unlisten()
 
     const e = [...agent.session.events]
@@ -1226,7 +1230,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
       return next()
     })
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -1241,7 +1245,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
     releaseAssemble()
     await waitForIdle(ctx, agent)
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
     unlisten()
 
     const e = [...agent.session.events]
@@ -1281,7 +1285,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
       await blocker
     })
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -1296,7 +1300,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
     const disposalDone = fiber.dispose()
     releasePreStep()
     await disposalDone
-    await agent.done
+    await driverDone(agent)
 
     // After the pre-step seam finishes, the post-seam cancel/dispose check
     // catches disposal. The step was never opened, no LLM call was made.
@@ -1333,7 +1337,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
       await blocker
     })
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -1348,7 +1352,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
     releasePreStep()
     await waitForIdle(ctx, agent)
     await fiber.dispose()
-    await agent.done
+    await driverDone(agent)
 
     const e = [...agent.session.events]
     expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
@@ -1383,7 +1387,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
       return next()
     })
 
-    let agent!: ReactLoopAgent
+    let agent!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
@@ -1394,7 +1398,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
     const disposalDone = fiber.dispose()
     releaseAssemble()
     await disposalDone
-    await agent.done
+    await driverDone(agent)
 
     const e = [...agent.session.events]
     expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)

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

@@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh
 
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { scopeOf } from '@deepseek-ai/dsh-scope'
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
   return (await harnessWithLoop(adapter)).ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {
@@ -718,7 +718,7 @@ describe('agent scope lifecycle', () => {
     const ctx = await harness()
     let ownerCtx!: Context
     let creating!: ReturnType<typeof ctx.agents.create>
-    let announced!: ReactLoopAgent
+    let announced!: Agent
     const statuses: string[] = []
     let scopeDisposed = false
     let observerSawLive = false
@@ -727,7 +727,7 @@ describe('agent scope lifecycle', () => {
     })
     ctx.on('agent/session-start', (agent) => {
       if (agent.id !== SessionId('session-start-dispose-s')) return
-      announced = agent as ReactLoopAgent
+      announced = agent
       disposeCurrentLifecycle(ownerCtx)
     })
     ctx.on('agent/session-start', (agent) => {

+ 3 - 3
packages/core/agent-loop/tests/tool-order.spec.ts

@@ -14,9 +14,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses
 import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
 import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { MockAdapter, textResponse } from './mock-adapter.ts'
 
 async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 3 - 3
packages/core/agent-loop/tests/turn-stop.spec.ts

@@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
-import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as Invariants from '@deepseek-ai/dsh-invariants'
 import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
@@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
   return ctx
 }
 
-function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
+function send(agent: Agent, text = 'go'): Promise<void> {
   agent.send([{ type: 'text', text }])
   return agent.whenIdle()
 }

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

@@ -168,9 +168,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
 
 /**
  * The agent handle — the surface every plugin (UI, hooks, orchestrators)
- * programs against. The concrete implementation lives in
- * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
- * package should depend on the implementation.
+ * programs against. The concrete implementation is package-internal to
+ * `@deepseek-ai/dsh-agent-loop`; nothing outside that package depends on it.
  */
 export interface Agent {
   /** The single identity shared with {@link session}. */

+ 6 - 6
packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts

@@ -4,9 +4,9 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
 import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -34,12 +34,12 @@ async function harness(config: Config = {}): Promise<Context> {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
 }
 
 /** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
-function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] {
+function reminders(agent: Agent): { text: string; source: unknown }[] {
   return [...agent.session.events]
     .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
     .map(e => ({
@@ -255,14 +255,14 @@ describe('chain semantics', () => {
     ]))
     // Loop agents are torn down by disposing the scope that created them
     // (the loop.spec pattern): a child plugin fiber owns `first`.
-    let first!: ReactLoopAgent
+    let first!: Agent
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' })
     }, { inject: ['agentLoop'] }))
     first.send([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, first)
     await fiber.dispose()
-    await first.done
+    await first.whenIdle()
 
     const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' })
     second.send([{ type: 'text', text: 'go' }])

+ 4 - 4
packages/hooks/hooks-claude/tests/bridge.spec.ts

@@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -58,7 +58,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
   return { ctx, hooks }
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -66,7 +66,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
   })
 }
 
-function events(agent: ReactLoopAgent): SessionEvent[] {
+function events(agent: Agent): SessionEvent[] {
   return [...agent.session.events]
 }
 

+ 7 - 7
packages/hooks/hooks-claude/tests/coverage.spec.ts

@@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -42,10 +42,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
   ctx.llm.registerAdapter(['mock'], adapter)
   return ctx
 }
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
 }
-function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
+function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
 /** Poll until `predicate` holds or the deadline passes — robust to detached
  * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
 async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -451,8 +451,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
     const { SessionId } = await import('@deepseek-ai/dsh-session')
     const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
     handle.agent.send([{ type: 'text', text: 'go' }])
-    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
-    expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
+    await waitForIdle(ctx, handle.agent)
+    expect(events(handle.agent).some(e => e.type === 'context/message'
       && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
     await handle.dispose()
   })
@@ -616,7 +616,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
     const { SessionId } = await import('@deepseek-ai/dsh-session')
     const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
     handle.agent.send([{ type: 'text', text: 'go' }])
-    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
+    await waitForIdle(ctx, handle.agent)
 
     expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
     const { readFileSync } = await import('node:fs')

+ 4 - 4
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -54,14 +54,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') { dispose(); resolve() }
     })
   })
 }
-function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
+function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
 
 /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
 async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {

+ 5 - 5
packages/hooks/hooks-codex/tests/coverage.spec.ts

@@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId, type SessionEvent } 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -33,10 +33,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: { stderrS
   ctx.llm.registerAdapter(['mock'], adapter)
   return ctx
 }
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
 }
-function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
+function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
 /** Poll until `predicate` holds or the deadline passes — robust to detached
  * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
 async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -556,7 +556,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
     const { SessionId } = await import('@deepseek-ai/dsh-session')
     const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
     handle.agent.send([{ type: 'text', text: 'go' }])
-    await waitForIdle(ctx, handle.agent as ReactLoopAgent)
+    await waitForIdle(ctx, handle.agent)
     expect(existsSync(marker)).toBe(true)
     expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
     await handle.dispose()

+ 3 - 3
packages/todo/tool-todo/tests/integration.spec.ts

@@ -5,9 +5,9 @@ import SessionStore, { 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, { type Agent } from '@deepseek-ai/dsh-agent'
 
-import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
 import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
   return ctx
 }
 
-function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
+function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
   return new Promise((resolve) => {
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'idle') {

+ 1 - 1
packages/ui/acp/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-acp
 
-The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
+The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own concrete `Agent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
 
 It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
 

+ 1 - 1
packages/ui/acp/src/index.ts

@@ -18,7 +18,7 @@
  *                      turn about to start) + settle the in-flight prompt
  *
  * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
- * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id;
+ * its own concrete `Agent`. Sessions are keyed by their shared agent/session id;
  * every `session/event` and `agent/*` event is routed strictly to its owning
  * session record, so two sessions streaming at once never interleave their
  * `session/update` notifications. Permission prompts use the same identity: the