From cf5ed4c39a3c21fb7c2b1074376071e6de815fcd Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 11:56:15 -0700 Subject: [PATCH 001/254] docs: design spec for dsh stream-native harness prototype Records the verified ACP transport facts, the decisions (ACP over SDK, one process per agent, Dispatch as the UI, minimal seam), the component layout under apps/server/src/agents/dsh, the feed entry additions, and the explicit prototype cut line. Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-04-dsh-harness-design.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-dsh-harness-design.md diff --git a/docs/superpowers/specs/2026-09-04-dsh-harness-design.md b/docs/superpowers/specs/2026-09-04-dsh-harness-design.md new file mode 100644 index 000000000..08a57d53b --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-dsh-harness-design.md @@ -0,0 +1,254 @@ +# dsh: a stream-native harness for Dispatch + +Status: approved direction, prototype scope. Written 2026-09-04. + +## Why + +Every harness Dispatch drives today is a closed loop. Claude Code, Codex, Cursor +and OpenCode each own their agent loop, so Dispatch integrates from the outside: +the tmux pane is the agent, messages are pasted into the pane, tokens are scraped +from log files on disk, and status depends on the agent remembering to call +`dispatch_event`. That is a lot of scaffolding around something Dispatch cannot +see into. + +DeepSeek Harness (dsh, `deepseek-ai/deepseek-harness`, MIT, TypeScript) is an +open agent runtime built as a plugin tree. It exposes the loop: a durable session +event log, a persona seam, tool restriction, an MCP client, and two stdio +protocols for driving it from another process. Building a Dispatch profile on top +of it turns every workaround above into a structural feature, and gives Dispatch a +model-agnostic agent type (DeepSeek, OpenAI, Anthropic, any OpenAI-compatible +endpoint) whose UI is Dispatch itself. + +This spec covers the prototype: enough to launch a dsh agent from the Dispatch UI, +watch it work in the Chat tab, talk to it, and run a persona on it. It names what +the prototype leaves out so scope is a decision, not an accident. + +## Verified facts the design rests on + +Probed on 2026-09-04 against `@deepseek-ai/dsh@0.1.2-rc.1` with +`@agentclientprotocol/sdk@1.4.0`. The probe script is throwaway and lives outside +the repo. + +- `dsh --profile acp` speaks Agent Client Protocol v1 over stdio. `initialize` + advertises HTTP MCP support and session `close`, `list`, `resume`. +- `session/new` accepts `cwd` and an `mcpServers` list. A streamable HTTP MCP + server with an `Authorization: Bearer` header connects, initializes, and has + its tools listed before the session is published. A failed MCP connection + fails the session, loudly. +- `session/new` returns `configOptions`: a grouped model select drawn from the + live LLM catalog, plus a reasoning-effort select. `session/set_config_option` + changes either per session. +- A `--patch ` overlay on the CLI replaces any config row by id. Adding + provider routes to the `llm-pi-ai` row made the whole OpenAI catalog appear in + the model select with no key present. Replacing the `system-prompt` row's + `persona` field replaces the deployment persona. +- `session/prompt` without a credential for the selected route fails with a + clear `-32603` error naming the env var. Keys are read from the launching + environment or dsh's credential store. +- `session/update` kinds that matter: `agent_message_chunk`, + `agent_thought_chunk`, `tool_call` (with `kind`, `status`, `title`, + `locations`, and `content` that can be `content`, `diff`, or `terminal`), + `tool_call_update`, `usage_update` (input, output, thought, cache read, cache + write tokens, context size), `config_option_update`. +- `session/request_permission` is a client-answered request with allow and + reject options. The acp profile ships `approval: ask` and + `sandbox: workspace-write` by default, switchable to `danger-full-access` + with `DSH_PERMISSION_MODE`. +- dsh has no terminal UI. Its shipped applications are web, headless, sdk, and + acp. + +## Decisions + +**ACP over SDK.** dsh's own SDK protocol streams every durable session event but +has no per-session close, no cancel, no permission prompts on the wire, and no +per-session MCP attach. ACP has all four, and it is a standard: the same client +driver can later drive Zed's Claude Code and Codex ACP adapters. The prototype +gives up dsh-specific presentation cards for that, which is the right trade. + +**One process per agent.** ACP multiplexes sessions on one connection, but one +`dsh` process per Dispatch agent keeps isolation, cwd, environment, and teardown +identical to how every other agent type behaves. Sharing comes later if it earns +it. + +**Dispatch is the UI.** No dsh web client is embedded or themed. The Chat tab +renders the stream. The Console tab keeps a plain login shell in the worktree, +which the `terminal` agent type already provides. + +**Minimal seam, not the full refactor.** The prototype adds dsh beside the +existing per-type branches. It introduces one new module boundary, the stream +driver, and touches the existing branch sites only where dsh must diverge. A full +harness-adapter extraction is a separate decision after the prototype proves +itself. + +**Match Claude's permission posture.** Dispatch launches Claude Code with the +Dispatch MCP and no approval gating in the pane. The prototype launches dsh with +`DSH_PERMISSION_MODE=danger-full-access` so `request_permission` never fires. +Routing permission prompts into the Chat tab as an approval card is a follow-up. + +## Architecture + +``` +Dispatch server dsh (child process per agent) +───────────────────────────── ───────────────────────────── +AgentManager.createAgent(type="dsh") + └─ setup script in tmux (worktree, deps, hooks) + └─ exec login shell ◄── Console tab + └─ completeSetup() + └─ DshDriver.start(agent) ──spawn──► dsh --profile acp --patch + initialize │ + session/new { cwd, mcpServers: │ mcp-client ──HTTP+bearer──► /api/mcp/ + [dispatch HTTP + bearer] } │ (dispatch_event, chat, pins, repo tools) + prompt / cancel / close │ + ◄── session/update ─────────────────┘ + └─ StreamRecorder ─► agent_stream_events ─► Chat feed (assistant text, activity cards) + └─ StatusDeriver ─► agent status (working / idle) + └─ UsageRecorder ─► agent_token_usage +``` + +### Components + +**`packages/shared/src/agent-types.ts`** gains `"dsh"` in `AGENT_TYPES` and +`CLI_AGENT_TYPES`. dsh is eligible for jobs, reviews, and personas from day one. +`PLUGIN_AGENT_TYPES` stays `claude` and `codex`; dsh needs no plugin install. + +**`apps/server/src/agents/dsh/`** is the new module. Nothing outside it imports +the ACP SDK. + +- `driver.ts`: `DshDriver` owns one child process per agent. `start(agent)` + spawns `dsh --profile acp --patch ` with `cwd` set to the agent's + effective cwd, runs `initialize`, then `session/new` (or `session/resume` when + the agent record already holds a dsh session id) with Dispatch's HTTP MCP + server attached. `prompt(agentId, text)`, `cancel(agentId)`, + `stop(agentId)`, and `setModel(agentId, provider, model)` map one-to-one onto + ACP calls. It emits typed events (`update`, `status`, `exit`) that the + recorders below consume. Teardown walks stdin EOF, SIGTERM, SIGKILL, and + records the exit for `readExitInfo` parity. +- `overlay.ts`: builds the per-agent patch file from Dispatch state. Rows it + writes: `llm-pi-ai` provider routes from the dsh model catalog; `system-prompt` + persona text, which is the launch guidance, plus the persona brief or active + personality when present; `agent-default-model` from the chosen model. The + file lives under the agent's worktree metadata dir, never in the repo tree. +- `stream-recorder.ts`: folds `session/update` into `agent_stream_events` rows. + Assistant chunks accumulate into one row per message until the next non-chunk + update or turn end. Tool calls get one row keyed by `toolCallId` and are + updated in place on `tool_call_update`. Thoughts are stored but collapsed by + default. +- `status-deriver.ts`: a prompt in flight is `working`; settled is `idle`. + `done`, `blocked`, and `waiting_user` still come from the agent calling + `dispatch_event`, because those are judgments only the agent can make. The + activity monitor's pane-digest heuristic is skipped for dsh agents. +- `usage-recorder.ts`: `usage_update` carries cumulative totals. The recorder + upserts `agent_token_usage` keyed by agent, dsh session id, and the model + named by the latest `config_option_update`. This replaces the token harvester + for dsh; the harvester's dispatch returns early for the type. +- Model catalog: a `dsh` entry in the existing `AGENT_MODEL_OPTIONS` map in + `apps/server/src/shared/agent-models.ts`. Static for the prototype: DeepSeek + V4 Flash and Pro, and the OpenAI GPT-5 family the probe listed. Each id is + `provider/model` (for example `deepseek-official/deepseek-v4-flash`) so the + agent record's existing `model` string carries both halves unchanged, and + `overlay.ts` splits it. + +**`apps/server/src/agents/tmux/command-builder.ts`** returns the `terminal` +login-shell command for dsh. The setup script runs unchanged, so worktree +creation, dependency install, local config copy, and lifecycle hooks all apply. + +**`apps/server/src/agents/manager.ts`** calls `DshDriver.start` from +`completeSetup` when the agent type is dsh, `DshDriver.stop` from stop and +archive paths, and stores the dsh session id in the existing `cliSessionId` +column. Restart resumes rather than recreates. + +**`apps/server/src/server/agent-prompts.ts`** gains a dsh branch in +`enqueueAgentPrompt`: the prompt text goes to `DshDriver.prompt` instead of the +pane. Every existing caller keeps working: chat user messages, cross-agent +messages, quick prompts, shortcut pins, and persona launch context all flow +through this one function already. The cross-agent message envelope is kept +verbatim for the prototype so the agent's instructions about `replyTarget` +still apply. + +**`apps/server/src/chat/feed.ts`** reads `agent_stream_events` as a sixth feed +source and emits two new `ChatFeedEntry` variants defined in +`packages/shared/src/chat-types.ts`: + +- `ChatAssistantEntry { type: "assistant"; id; text; at; streaming: boolean }` +- `ChatActivityEntry { type: "activity"; id; toolKind; title; status; +locations; diff?: { path; oldText; newText }; terminalOutput?: string; at }` + +Assistant entries render with the same markdown block the agent chat message +uses. Activity entries render as compact rows with the existing status colours +and open inline for diffs and terminal output. The Chat tab is the default tab +for dsh agents. + +**`apps/web`** adds the dsh icon and label to `agent-type-icon.tsx`, +`agent-type-select.tsx`, and `agent-type-settings.tsx`, the model options to +the picker, and the two new entry renderers to the chat feed. All styling uses +the existing theme tokens; nothing dsh-specific is introduced. + +**`apps/server/src/config.ts`** gains `dshBin` (default `dsh` on PATH) and +`dshHome` (default `~/.dispatch/dsh`). On first launch Dispatch initializes the +`acp` profile under that home so dsh's own `~/.dsh` is never touched. + +### Data + +Migration `agent_stream_events(id, agent_id, seq, kind, payload jsonb, +created_at)` with an index on `(agent_id, seq)`. Rows are append-only except +tool-call updates, which rewrite the row for their `toolCallId`. The feed reads +the last N by seq. Retention follows the agent: archive keeps, delete cascades. + +### Personas + +`dispatch_launch_persona` with `agentType: "dsh"` follows the normal launch path. +The assembled persona prompt goes into the overlay's `system-prompt.persona` +field instead of `--append-system-prompt`, so the 8KB cap does not apply. The +review tool whitelist stays enforced by the Dispatch MCP server exactly as +today, so the reviewer contract (`working`, `dispatch_review_submit` once, +`done`) is unchanged. Native `toolFilter` restriction is a follow-up. + +### Credentials + +The driver passes `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, and `ANTHROPIC_API_KEY` +through from the server environment when set. A missing key surfaces as the +`-32603` error text in the Chat tab as a status entry, not as a silent stall. +A Dispatch settings page for keys is a follow-up. + +### Error handling + +- dsh exits unexpectedly: the driver records the exit, marks the agent `blocked` + with the last stderr line, and leaves the tmux shell up so the user can + inspect. Restart re-spawns and resumes the session. +- MCP attach fails at `session/new`: the launch fails with the ACP error text; + the agent lands in the same failed-setup state a bad CLI command would. +- Prompt rejected (`-32603`): recorded as a status entry with the message; the + agent returns to `idle`. +- Cancel: the stop button on a dsh agent calls `session/cancel` first, then + `close` on a hard stop. + +### Testing + +- Unit (vitest, `apps/server/test`): overlay builder output for each input + combination; stream recorder folding of chunk sequences and tool call + updates; usage recorder upsert math; status derivation; feed mapping to + entries. The driver is tested against a fake ACP agent built with the SDK's + `AgentSideConnection` over in-memory streams, covering initialize, new, + resume, prompt, cancel, close, and process-exit paths. No test spawns the + real dsh binary. +- E2E (Playwright): launch a dsh agent against a fake `dsh` shim on PATH that + speaks ACP and scripts a short turn with one tool call. Assert the type + appears in the picker, the Chat tab shows an assistant entry and an activity + row, status flips working then idle, and a chat message from the user + reaches the shim as a prompt. +- Manual: one live turn against DeepSeek or OpenAI with a real key before + calling the prototype done. + +## Out of scope for the prototype + +Named so they are chosen later, not forgotten. + +- Permission prompts as approval cards in the Chat tab. +- Native persona `toolFilter` and `complete` mode through agent presets. +- Agent Teams mailbox bridged to Dispatch messages. +- Dynamic model catalog read from `session/new` at settings time. +- Credential entry UI. ChatGPT-plan OAuth. +- A themed deep link into dsh's trajectory view for fork and replay. +- The full harness-adapter extraction across all agent types. +- Shared dsh process across agents. +- Image prompts. From b6856e7b09beb9817ba542c7acced6b36da0b94c Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:04:04 -0700 Subject: [PATCH 002/254] docs: implementation plan for the dsh harness prototype Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-09-04-dsh-harness.md | 2998 +++++++++++++++++ 1 file changed, 2998 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-dsh-harness.md diff --git a/docs/superpowers/plans/2026-09-04-dsh-harness.md b/docs/superpowers/plans/2026-09-04-dsh-harness.md new file mode 100644 index 000000000..120cab9a7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-dsh-harness.md @@ -0,0 +1,2998 @@ +# dsh Harness Prototype Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `dsh` as a first-class Dispatch agent type whose process is driven over the Agent Client Protocol, with its stream rendered in the Chat tab, status and tokens derived from the stream, and prompts, messages, and personas delivered through the same path. + +**Architecture:** A new server module `apps/server/src/agents/dsh/` owns one `dsh --profile acp` child process per agent over stdio JSON-RPC (ACP), attaches Dispatch's HTTP MCP server at session creation, and folds `session/update` notifications into a new `agent_stream_events` table that the Chat feed reads as a sixth source. The existing tmux setup script still creates the worktree and then drops into a login shell for the Console tab. Everything else (chat, messages, personas, pins, repo tools) reaches the agent through the existing MCP server and the existing `enqueueAgentPrompt` seam, which gains one branch. + +**Tech Stack:** TypeScript, Node 22+, Fastify, PostgreSQL, `@agentclientprotocol/sdk@1.4.0`, `yaml@2`, vitest, Playwright, React 18 + Tailwind + shadcn. + +**Spec:** `docs/superpowers/specs/2026-09-04-dsh-harness-design.md` + +## Global Constraints + +- Use `pnpm`, never npm, for every install and script. +- Stay in this worktree. Never `cd` to the parent repo. +- Never run `pnpm run dev`; use the `repo_dev_*` MCP tools for a dev stack. +- Nothing outside `apps/server/src/agents/dsh/` imports `@agentclientprotocol/sdk`. +- No test spawns the real `dsh` binary. Unit tests use an in-memory fake ACP agent; E2E uses a shim on PATH. +- The dsh child is launched with `DSH_PERMISSION_MODE=danger-full-access` (spec decision "Match Claude's permission posture"). +- `DSH_HOME` for launched agents defaults to `~/.dispatch/dsh`, never the user's own `~/.dsh`. +- Model ids are `provider/model`, for example `deepseek-official/deepseek-v4-flash`. +- Every `apps/web` change ends with `pnpm run finalize:web`. Every task ends with `pnpm run check`. +- Commit after every task with the `Co-Authored-By: Claude Fable 5.1 ` trailer. + +--- + +## File map + +| File | Responsibility | +| ------------------------------------------------------------ | --------------------------------------------------------------------- | +| `packages/shared/src/agent-types.ts` | add `"dsh"` to `AGENT_TYPES` and `CLI_AGENT_TYPES` | +| `packages/shared/src/chat-types.ts` | add `ChatAssistantEntry`, `ChatActivityEntry`, extend `ChatFeedEntry` | +| `packages/shared/src/index.ts` | export the two new types | +| `apps/server/src/config.ts` | `dshBin`, `dshHome` | +| `apps/server/src/shared/agent-models.ts` | `dsh` catalog entry | +| `apps/server/src/agents/tmux/command-builder.ts` | `dsh` launches the login shell | +| `apps/server/src/agents/token-harvester.ts` | early return for `dsh` | +| `apps/server/src/db/migrations/0048_agent-stream-events.sql` | new table | +| `apps/server/src/agents/dsh/stream-store.ts` | rows in `agent_stream_events` | +| `apps/server/src/agents/dsh/overlay.ts` | per-agent `--patch` YAML | +| `apps/server/src/agents/dsh/driver.ts` | ACP client, one child per agent | +| `apps/server/src/agents/dsh/stream-recorder.ts` | `session/update` to store rows | +| `apps/server/src/agents/dsh/usage-recorder.ts` | `usage_update` to `agent_token_usage` | +| `apps/server/src/agents/dsh/supervisor.ts` | glue: start on setup complete, stop, status, prompt | +| `apps/server/src/agents/manager.ts` | call the supervisor at lifecycle points | +| `apps/server/src/server/agent-prompts.ts` | route dsh prompts to the supervisor | +| `apps/server/src/chat/feed.ts` | `listStreamEntries` source | +| `apps/web/src/lib/agent-types.ts` | label | +| `apps/web/src/components/app/agent-type-settings.tsx` | description | +| `apps/web/src/components/app/agent-type-icon.tsx` | icon | +| `apps/web/src/components/app/chat/chat-entries.tsx` | `AssistantEntryView`, `ActivityEntryView` | +| `apps/web/src/components/app/chat/chat-feed.tsx` | render the two new entries | +| `e2e/fixtures/fake-dsh.mjs` | ACP shim on PATH | +| `e2e/dsh-agent.spec.ts` | end-to-end | +| `docs/agent-model-catalog.md` | dsh section | + +--- + +### Task 1: Register the `dsh` agent type, config, catalog, and labels + +**Files:** + +- Modify: `packages/shared/src/agent-types.ts` +- Modify: `apps/server/src/config.ts:18-37` and `:85-97` +- Modify: `apps/server/src/shared/agent-models.ts:27-45` +- Modify: `apps/server/src/agents/tmux/command-builder.ts:540-542` +- Modify: `apps/server/src/agents/token-harvester.ts:317-325` +- Modify: `apps/web/src/lib/agent-types.ts:19-25` +- Modify: `apps/web/src/components/app/agent-type-settings.tsx:16-22` +- Modify: `apps/web/src/components/app/agent-type-icon.tsx:28-48` and the label/icon branches below it +- Modify: `docs/agent-model-catalog.md` +- Test: `apps/server/test/agent-models.test.ts`, `apps/server/test/tmux-command-builder.test.ts` + +**Interfaces:** + +- Produces: `AgentType` now includes `"dsh"`; `AppConfig.dshBin: string`, `AppConfig.dshHome: string`; `AGENT_MODEL_OPTIONS.dsh` with ids `deepseek-official/deepseek-v4-flash`, `deepseek-official/deepseek-v4-pro`, `openai/gpt-5.2`, `openai/gpt-5.3-codex`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/server/test/agent-models.test.ts`: + +```ts +describe("dsh catalog", () => { + it("lists provider-qualified ids for dsh", () => { + const ids = (AGENT_MODEL_OPTIONS.dsh ?? []).map((o) => o.id); + expect(ids).toContain("deepseek-official/deepseek-v4-flash"); + expect(ids).toContain("openai/gpt-5.2"); + for (const id of ids) expect(id).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/); + }); +}); +``` + +Append to `apps/server/test/tmux-command-builder.test.ts` (copy the `terminal` case's setup in that file for `config`, `agentId`, and the builder call): + +```ts +it("launches dsh agents into a login shell like terminal agents", () => { + const command = buildAgentCommand({ + ...baseInput, + type: "dsh", + }); + expect(command).toContain('"${SHELL:-/bin/bash}" -il'); + expect(command).not.toContain("--mcp-config"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @dispatch/server exec vitest run test/agent-models.test.ts test/tmux-command-builder.test.ts` +Expected: FAIL. TypeScript rejects `"dsh"` as an `AgentType`; the catalog has no `dsh` key. + +- [ ] **Step 3: Add the type** + +`packages/shared/src/agent-types.ts`: + +```ts +export const AGENT_TYPES = [ + "claude", + "codex", + "cursor", + "opencode", + "dsh", + "terminal", +] as const; +export type AgentType = (typeof AGENT_TYPES)[number]; + +export const CLI_AGENT_TYPES = [ + "claude", + "codex", + "cursor", + "opencode", + "dsh", +] as const; +export type CliAgentType = (typeof CLI_AGENT_TYPES)[number]; +``` + +- [ ] **Step 4: Add config fields** + +`apps/server/src/config.ts`. In `AppConfig` after `cursorBin: string;` add: + +```ts +/** Path to the `dsh` launcher (DeepSeek Harness). */ +dshBin: string; +/** DSH_HOME for agents Dispatch launches; never the user's own ~/.dsh. */ +dshHome: string; +``` + +In the config object after `cursorBin:` add: + +```ts + dshBin: process.env.DISPATCH_DSH_BIN ?? process.env.DSH_BIN ?? "dsh", + dshHome: resolveConfiguredPath( + process.env.DISPATCH_DSH_HOME ?? path.join(os.homedir(), ".dispatch", "dsh") + ), +``` + +- [ ] **Step 5: Add the catalog entry** + +`apps/server/src/shared/agent-models.ts`, inside `AGENT_MODEL_OPTIONS` after the `claude` array: + +```ts + // dsh ids are `provider/model`: the provider is a dsh LLM route name and the + // model is that route's id. Verified against `dsh --profile acp` session + // configOptions on 2026-09-04 (see docs/agent-model-catalog.md). + dsh: [ + { id: "deepseek-official/deepseek-v4-flash", label: "DeepSeek V4 Flash" }, + { id: "deepseek-official/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, + { id: "openai/gpt-5.2", label: "GPT-5.2 (OpenAI API key)" }, + { id: "openai/gpt-5.3-codex", label: "GPT-5.3 Codex (OpenAI API key)" }, + ], +``` + +- [ ] **Step 6: Launch command and harvester** + +`apps/server/src/agents/tmux/command-builder.ts` line 540: change the terminal branch condition to + +```ts +if (type === "terminal" || type === "dsh") { + return `${envPrefix} "\${SHELL:-/bin/bash}" -il`; +} +``` + +If `CLI_BY_AGENT_TYPE` is typed as `Record`, add `dsh: "dshBin"` to it so the type still checks; the branch above returns before it is read. + +`apps/server/src/agents/token-harvester.ts` `harvestTokenUsage`: add before the codex check: + +```ts +// dsh usage arrives on the ACP stream (agents/dsh/usage-recorder.ts). +if (agent.type === "dsh") return; +``` + +- [ ] **Step 7: Web labels and icon** + +`apps/web/src/lib/agent-types.ts`: + +```ts +export const AGENT_TYPE_LABELS: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + opencode: "OpenCode", + dsh: "DSH", + terminal: "Terminal", +}; +``` + +`apps/web/src/components/app/agent-type-settings.tsx`: + +```ts + dsh: "DeepSeek Harness (dsh) — open-source, model-agnostic, streams into the Chat tab.", +``` + +`apps/web/src/components/app/agent-type-icon.tsx`: extend the union returned by `normalizeAgentType` with `"dsh"`, add `if (type === "dsh") return "dsh";`, add `dsh` to the label ternary (`"DSH"`), and render it with the same `Terminal`-style glyph path the `terminal` branch uses but with a `--chart-3` stroke. Whatever component the terminal branch returns, copy that JSX for `dsh` and change only the colour token. Do not add an SVG asset. + +- [ ] **Step 8: Docs** + +Append to `docs/agent-model-catalog.md`: + +```md +## dsh (DeepSeek Harness) + +Ids are `provider/model`. Evidence bar: the id must appear in the `model` +config option returned by `session/new` on `dsh --profile acp` for the +installed version. Procedure: run the ACP probe (or `dsh --profile acp +--dump-config` plus a `session/new`) and copy the `value` pairs verbatim. +Routes other than `deepseek-official` need their provider declared in the +overlay's `llm-pi-ai` row; `openai` is declared by default. +``` + +- [ ] **Step 9: Run tests and type check** + +Run: `pnpm --filter @dispatch/server exec vitest run test/agent-models.test.ts test/tmux-command-builder.test.ts test/agent-type-settings.test.ts && pnpm run check` +Expected: PASS, no type errors. Fix every exhaustiveness error the compiler raises (any `Record` now needs a `dsh` key). + +- [ ] **Step 10: Commit** + +```bash +git add -A packages/shared apps/server/src apps/server/test apps/web/src docs/agent-model-catalog.md +git commit -m "feat(dsh): register the dsh agent type, config, and model catalog + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: `agent_stream_events` migration and `StreamStore` + +**Files:** + +- Create: `apps/server/src/db/migrations/0048_agent-stream-events.sql` +- Create: `apps/server/src/agents/dsh/stream-store.ts` +- Test: `apps/server/test/dsh-stream-store.test.ts` + +**Interfaces:** + +- Produces: + +```ts +export type StreamEventKind = "assistant" | "thought" | "tool_call" | "status"; +export type StreamEventRow = { + id: number; + agentId: string; + seq: number; + kind: StreamEventKind; + key: string | null; // toolCallId for tool_call rows, else null + payload: Record; + createdAt: Date; + updatedAt: Date; +}; +export class StreamStore { + constructor(private readonly db: Queryable) {} + append( + agentId: string, + kind: StreamEventKind, + payload: Record, + key?: string | null + ): Promise; + upsertByKey( + agentId: string, + kind: StreamEventKind, + key: string, + payload: Record + ): Promise; + latest( + agentId: string, + kind: StreamEventKind + ): Promise; + updatePayload(id: number, payload: Record): Promise; + list(agentId: string, limit: number): Promise; +} +``` + +`Queryable` is the same type `apps/server/src/chat/feed.ts` imports; reuse that import. + +- [ ] **Step 1: Write the failing test** + +`apps/server/test/dsh-stream-store.test.ts`: + +```ts +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { StreamStore } from "../src/agents/dsh/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_stream_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'Stream A', '/tmp', 'running')`, + [A] + ); +}); +afterAll(async () => { + await teardownTestDb(); +}); +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +describe("StreamStore", () => { + it("appends rows with a per-agent increasing seq", async () => { + const a = await store.append(A, "assistant", { text: "hi" }); + const b = await store.append(A, "status", { message: "x" }); + expect(b.seq).toBe(a.seq + 1); + expect(a.key).toBeNull(); + }); + + it("upserts a tool call by key without changing its seq", async () => { + const first = await store.upsertByKey(A, "tool_call", "call_1", { + status: "pending", + }); + const second = await store.upsertByKey(A, "tool_call", "call_1", { + status: "completed", + }); + expect(second.id).toBe(first.id); + expect(second.seq).toBe(first.seq); + expect(second.payload).toEqual({ status: "completed" }); + }); + + it("returns the latest row of a kind and updates a payload in place", async () => { + const row = await store.append(A, "assistant", { text: "a" }); + await store.updatePayload(row.id, { text: "ab" }); + const latest = await store.latest(A, "assistant"); + expect(latest?.id).toBe(row.id); + expect(latest?.payload).toEqual({ text: "ab" }); + }); + + it("lists newest first, bounded by limit", async () => { + for (let i = 0; i < 5; i++) await store.append(A, "status", { i }); + const rows = await store.list(A, 3); + expect(rows.map((r) => r.payload.i)).toEqual([4, 3, 2]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-stream-store.test.ts` +Expected: FAIL, module not found. + +- [ ] **Step 3: Write the migration** + +`apps/server/src/db/migrations/0048_agent-stream-events.sql`: + +```sql +-- Stream events from harnesses Dispatch drives over a protocol (dsh over +-- ACP). One row per assistant message, thought, or tool call; tool calls are +-- rewritten in place as their status changes (key = toolCallId). seq orders +-- rows within one agent and never changes after insert. +CREATE TABLE IF NOT EXISTS agent_stream_events ( + id bigserial PRIMARY KEY, + agent_id text NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + seq integer NOT NULL, + kind text NOT NULL CHECK (kind IN ('assistant','thought','tool_call','status')), + key text, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (agent_id, seq) +); +CREATE UNIQUE INDEX IF NOT EXISTS agent_stream_events_agent_key + ON agent_stream_events (agent_id, kind, key) WHERE key IS NOT NULL; +CREATE INDEX IF NOT EXISTS agent_stream_events_agent_created + ON agent_stream_events (agent_id, created_at DESC, id DESC); +``` + +- [ ] **Step 4: Write the store** + +`apps/server/src/agents/dsh/stream-store.ts`: + +```ts +import type { Queryable } from "../../chat/feed.js"; + +export type StreamEventKind = "assistant" | "thought" | "tool_call" | "status"; + +export type StreamEventRow = { + id: number; + agentId: string; + seq: number; + kind: StreamEventKind; + key: string | null; + payload: Record; + createdAt: Date; + updatedAt: Date; +}; + +type Row = { + id: string | number; + agent_id: string; + seq: number; + kind: StreamEventKind; + key: string | null; + payload: Record; + created_at: Date; + updated_at: Date; +}; + +function toRow(r: Row): StreamEventRow { + return { + id: Number(r.id), + agentId: r.agent_id, + seq: r.seq, + kind: r.kind, + key: r.key, + payload: r.payload, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +const INSERT = ` + INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + SELECT $1, COALESCE(MAX(seq), 0) + 1, $2, $3, $4::jsonb + FROM agent_stream_events WHERE agent_id = $1 + RETURNING *`; + +export class StreamStore { + constructor(private readonly db: Queryable) {} + + async append( + agentId: string, + kind: StreamEventKind, + payload: Record, + key: string | null = null + ): Promise { + const result = await this.db.query(INSERT, [ + agentId, + kind, + key, + JSON.stringify(payload), + ]); + return toRow(result.rows[0]); + } + + async upsertByKey( + agentId: string, + kind: StreamEventKind, + key: string, + payload: Record + ): Promise { + const existing = await this.db.query( + `SELECT * FROM agent_stream_events WHERE agent_id = $1 AND kind = $2 AND key = $3`, + [agentId, kind, key] + ); + if (existing.rows[0]) { + const updated = await this.db.query( + `UPDATE agent_stream_events SET payload = $2::jsonb, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [existing.rows[0].id, JSON.stringify(payload)] + ); + return toRow(updated.rows[0]); + } + return this.append(agentId, kind, payload, key); + } + + async latest( + agentId: string, + kind: StreamEventKind + ): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events WHERE agent_id = $1 AND kind = $2 + ORDER BY seq DESC LIMIT 1`, + [agentId, kind] + ); + return result.rows[0] ? toRow(result.rows[0]) : null; + } + + async updatePayload( + id: number, + payload: Record + ): Promise { + await this.db.query( + `UPDATE agent_stream_events SET payload = $2::jsonb, updated_at = NOW() WHERE id = $1`, + [id, JSON.stringify(payload)] + ); + } + + async list(agentId: string, limit: number): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events WHERE agent_id = $1 + ORDER BY seq DESC LIMIT $2`, + [agentId, limit] + ); + return result.rows.map(toRow); + } +} +``` + +If `Queryable` is not exported from `feed.ts`, export it there (it is a `{ query(text, params) }` shape over `Pool`). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-stream-store.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/db/migrations/0048_agent-stream-events.sql apps/server/src/agents/dsh/stream-store.ts apps/server/test/dsh-stream-store.test.ts apps/server/src/chat/feed.ts +git commit -m "feat(dsh): agent_stream_events table and StreamStore + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: Chat feed entries for stream rows + +**Files:** + +- Modify: `packages/shared/src/chat-types.ts:117-190` +- Modify: `packages/shared/src/index.ts` (export the new types) +- Modify: `apps/server/src/chat/feed.ts:58-75` (`isValidCursorId`), `:180-216` (add source next to it), `:411-450` (`composeChatFeed`) +- Test: `apps/server/test/chat-feed.test.ts` + +**Interfaces:** + +- Consumes: `StreamStore` rows from Task 2 (read via SQL here, not via the class). +- Produces: + +```ts +export type ChatAssistantEntry = { + type: "assistant"; + id: string; // `stream:` + text: string; + streaming: boolean; + at: string; +}; +export type ChatActivityEntry = { + type: "activity"; + id: string; // `stream:` + toolKind: string; // ACP ToolKind or "other" + title: string; + status: "pending" | "in_progress" | "completed" | "failed"; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; + at: string; +}; +``` + +`ChatFeedEntry` gains both. Cursor `type` for either is `"assistant"` / `"activity"` with an int id. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/test/chat-feed.test.ts` (the file already sets up `pool`, `store`, and agent `A`; add `agent_stream_events` to the `beforeEach` DELETE list): + +```ts +describe("stream sources", () => { + it("surfaces assistant and tool_call rows as assistant and activity entries", async () => { + await pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) VALUES + ($1, 1, 'assistant', NULL, '{"text":"Reading files","streaming":false}'), + ($1, 2, 'tool_call', 'call_1', '{"toolKind":"read","title":"Read README.md","status":"completed","locations":[{"path":"/w/README.md"}],"diff":null,"terminalOutput":null}'), + ($1, 3, 'thought', NULL, '{"text":"hmm"}')`, + [A] + ); + const feed = await composeChatFeed(store, A); + const types = feed.entries.map((e) => e.type); + expect(types).toEqual(["assistant", "activity"]); + const activity = feed.entries[1]; + if (activity.type !== "activity") throw new Error("expected activity"); + expect(activity.title).toBe("Read README.md"); + expect(activity.status).toBe("completed"); + expect(activity.locations).toEqual([{ path: "/w/README.md" }]); + }); + + it("pages across stream entries with the cursor", async () => { + for (let i = 1; i <= 4; i++) { + await pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, payload) + VALUES ($1, $2, 'assistant', $3::jsonb)`, + [A, i, JSON.stringify({ text: `m${i}`, streaming: false })] + ); + } + const page1 = await composeChatFeed(store, A, { limit: 2 }); + expect(page1.hasMore).toBe(true); + const page2 = await composeChatFeed(store, A, { + limit: 2, + cursor: decodeFeedCursor(page1.nextCursor!), + }); + const texts = [...page2.entries, ...page1.entries].map((e) => + e.type === "assistant" ? e.text : "" + ); + expect(texts).toEqual(["m1", "m2", "m3", "m4"]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/chat-feed.test.ts` +Expected: FAIL. Entries are missing; the cursor type is rejected. + +- [ ] **Step 3: Add the shared types** + +`packages/shared/src/chat-types.ts`, after `ChatReviewEntry`: + +```ts +/** One assistant message from a stream-driven harness (dsh over ACP). */ +export type ChatAssistantEntry = { + type: "assistant"; + id: string; + text: string; + /** True while chunks are still arriving for this message. */ + streaming: boolean; + at: string; +}; + +export type ChatActivityStatus = + | "pending" + | "in_progress" + | "completed" + | "failed"; + +/** One tool call from a stream-driven harness, rewritten in place as it settles. */ +export type ChatActivityEntry = { + type: "activity"; + id: string; + toolKind: string; + title: string; + status: ChatActivityStatus; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; + at: string; +}; +``` + +Extend the union: + +```ts +export type ChatFeedEntry = + | ChatMessageEntry + | ChatStatusEntry + | ChatAgentMessageEntry + | ChatMediaEntry + | ChatReviewEntry + | ChatAssistantEntry + | ChatActivityEntry; +``` + +Add `ChatActivityEntry`, `ChatActivityStatus`, `ChatAssistantEntry` to the `chat-types.js` type export list in `packages/shared/src/index.ts`. + +- [ ] **Step 4: Add the feed source** + +In `apps/server/src/chat/feed.ts`, add `"assistant"` and `"activity"` to whatever `isValidCursorId` treats as integer ids (the same branch `status` uses). Then add next to `listStatusEntries`: + +```ts +type StreamPayload = { + text?: string; + streaming?: boolean; + toolKind?: string; + title?: string; + status?: ChatActivityStatus; + locations?: { path: string; line?: number }[]; + diff?: { path: string; oldText: string | null; newText: string } | null; + terminalOutput?: string | null; +}; + +async function listStreamEntries( + db: Queryable, + agentId: string, + cursor: FeedCursor | null, + limit: number +): Promise[]> { + const params: unknown[] = [agentId]; + // Both entry types share one source; the cursor clause keys on either. + const clause = + cursor && (cursor.type === "assistant" || cursor.type === "activity") + ? cursorClause(cursor.type, "int", cursor, params) + : cursorClause("assistant", "int", cursor, params); + params.push(limit); + const result = await db.query<{ + id: number; + kind: string; + payload: StreamPayload; + created_at: Date; + at_key: string; + }>( + `SELECT id, kind, payload, created_at, ${AT_KEY_SQL} AS at_key + FROM agent_stream_events + WHERE agent_id = $1 AND kind IN ('assistant','tool_call') ${clause} + ORDER BY created_at DESC, id DESC + LIMIT $${params.length}`, + params + ); + return result.rows.map((row) => { + const at = row.created_at.toISOString(); + const entry: ChatAssistantEntry | ChatActivityEntry = + row.kind === "assistant" + ? { + type: "assistant", + id: `stream:${row.id}`, + text: row.payload.text ?? "", + streaming: row.payload.streaming === true, + at, + } + : { + type: "activity", + id: `stream:${row.id}`, + toolKind: row.payload.toolKind ?? "other", + title: row.payload.title ?? "", + status: row.payload.status ?? "pending", + locations: row.payload.locations ?? [], + diff: row.payload.diff ?? null, + terminalOutput: row.payload.terminalOutput ?? null, + at, + }; + return { + entry, + atKey: row.at_key, + rawId: String(row.id), + idKey: intKey(row.id), + }; + }); +} +``` + +Read `cursorClause`'s signature before calling it; if its first parameter is the alias rather than the type, pass what `listStatusEntries` passes. In `composeChatFeed` add `listStreamEntries(db, agentId, cursor, limit + 1)` to the `Promise.all` and spread its result into `merged`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @dispatch/server exec vitest run test/chat-feed.test.ts && pnpm run check` +Expected: PASS. The web build will fail on the exhaustive `switch` in `chat-feed.tsx`; that is fixed in Task 9. If `pnpm run check` fails only there, note it and continue. + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src apps/server/src/chat/feed.ts apps/server/test/chat-feed.test.ts +git commit -m "feat(dsh): assistant and activity chat feed entries from stream rows + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Per-agent overlay builder + +**Files:** + +- Create: `apps/server/src/agents/dsh/overlay.ts` +- Test: `apps/server/test/dsh-overlay.test.ts` + +**Interfaces:** + +- Produces: + +```ts +export type OverlayInput = { + model: string | null; // "provider/model" or null for the profile default + persona: string; // full system prompt persona text (launch guidance + persona brief + personality) + providers?: Record; // extra llm-pi-ai routes; default { openai: { apiKeyEnv: "OPENAI_API_KEY" } } +}; +export type ProviderRoute = { + apiKeyEnv?: string; + baseURL?: string; + api?: string; + displayName?: string; + models?: { id: string; contextWindow?: number }[]; +}; +export function splitModelId(model: string): { + provider: string; + model: string; +}; +export function buildOverlayYaml(input: OverlayInput): string; +export async function writeOverlay( + dir: string, + agentId: string, + input: OverlayInput +): Promise; // returns file path +``` + +- [ ] **Step 1: Write the failing test** + +`apps/server/test/dsh-overlay.test.ts`: + +```ts +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parse } from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildOverlayYaml, + splitModelId, + writeOverlay, +} from "../src/agents/dsh/overlay.js"; + +describe("splitModelId", () => { + it("splits provider/model", () => { + expect(splitModelId("openai/gpt-5.2")).toEqual({ + provider: "openai", + model: "gpt-5.2", + }); + }); + it("rejects ids without a slash", () => { + expect(() => splitModelId("gpt-5.2")).toThrow("provider/model"); + }); +}); + +describe("buildOverlayYaml", () => { + it("emits llm routes, persona, and default model rows", () => { + const rows = parse( + buildOverlayYaml({ + model: "openai/gpt-5.2", + persona: "You are {{model}} in {{cwd}}.", + }) + ) as { id: string; config: Record }[]; + const byId = Object.fromEntries(rows.map((r) => [r.id, r.config])); + expect(byId["llm-pi-ai"]).toEqual({ + providers: { openai: { apiKeyEnv: "OPENAI_API_KEY" } }, + }); + expect(byId["system-prompt"]).toEqual({ + persona: "You are {{model}} in {{cwd}}.", + }); + expect(byId["agent-default-model"]).toEqual({ + provider: "openai", + model: "gpt-5.2", + }); + expect(byId["acp"]).toEqual({ provider: "openai", model: "gpt-5.2" }); + }); + + it("omits model rows when no model is chosen", () => { + const rows = parse(buildOverlayYaml({ model: null, persona: "p" })) as { + id: string; + }[]; + expect(rows.map((r) => r.id)).toEqual(["llm-pi-ai", "system-prompt"]); + }); +}); + +describe("writeOverlay", () => { + let dir: string; + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + it("writes /.patch.yml", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "dsh-overlay-")); + const file = await writeOverlay(dir, "agt_1", { + model: null, + persona: "p", + }); + expect(file).toBe(path.join(dir, "agt_1.patch.yml")); + expect(await readFile(file, "utf8")).toContain("system-prompt"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-overlay.test.ts` +Expected: FAIL, module not found. + +- [ ] **Step 3: Write the builder** + +`apps/server/src/agents/dsh/overlay.ts`: + +```ts +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stringify } from "yaml"; + +export type ProviderRoute = { + apiKeyEnv?: string; + baseURL?: string; + api?: string; + displayName?: string; + models?: { id: string; contextWindow?: number }[]; +}; + +export type OverlayInput = { + model: string | null; + persona: string; + providers?: Record; +}; + +const DEFAULT_PROVIDERS: Record = { + openai: { apiKeyEnv: "OPENAI_API_KEY" }, +}; + +export function splitModelId(model: string): { + provider: string; + model: string; +} { + const idx = model.indexOf("/"); + if (idx <= 0 || idx === model.length - 1) { + throw new Error(`dsh model ids are provider/model; got "${model}"`); + } + return { provider: model.slice(0, idx), model: model.slice(idx + 1) }; +} + +/** + * The per-agent `--patch` layer. Each entry replaces the config of the row + * with that id in the composed acp profile (see the spec's verified facts). + */ +export function buildOverlayYaml(input: OverlayInput): string { + const rows: { id: string; config: Record }[] = [ + { + id: "llm-pi-ai", + config: { providers: input.providers ?? DEFAULT_PROVIDERS }, + }, + { id: "system-prompt", config: { persona: input.persona } }, + ]; + if (input.model) { + const selected = splitModelId(input.model); + rows.push({ id: "agent-default-model", config: selected }); + rows.push({ id: "acp", config: selected }); + } + return stringify(rows); +} + +export async function writeOverlay( + dir: string, + agentId: string, + input: OverlayInput +): Promise { + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${agentId}.patch.yml`); + await writeFile(file, buildOverlayYaml(input), "utf8"); + return file; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-overlay.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/agents/dsh/overlay.ts apps/server/test/dsh-overlay.test.ts +git commit -m "feat(dsh): per-agent profile overlay builder + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: `DshDriver` over ACP + +**Files:** + +- Modify: `apps/server/package.json` (add `@agentclientprotocol/sdk`) +- Create: `apps/server/src/agents/dsh/driver.ts` +- Create: `apps/server/test/helpers/fake-acp-agent.ts` +- Test: `apps/server/test/dsh-driver.test.ts` + +**Interfaces:** + +- Produces: + +```ts +export type DriverLaunch = { + agentId: string; + cwd: string; + overlayPath: string; + mcp: { url: string; token: string }; + sessionId: string | null; // resume when set + env: NodeJS.ProcessEnv; +}; +export type DriverUpdate = import("@agentclientprotocol/sdk").SessionUpdate; // re-exported as a type only from this module +export type DriverEvent = + | { type: "update"; agentId: string; update: DriverUpdate } + | { + type: "turn"; + agentId: string; + state: "started" | "settled"; + stopReason?: string; + error?: string; + } + | { + type: "exit"; + agentId: string; + code: number | null; + signal: string | null; + stderrTail: string; + }; +export type DriverListener = (event: DriverEvent) => void; + +export class DshDriver { + constructor(opts: { + dshBin: string; + dshHome: string; + spawn?: SpawnFn; + logger: Logger; + }); + onEvent(listener: DriverListener): () => void; + start(launch: DriverLaunch): Promise<{ sessionId: string }>; + prompt(agentId: string, text: string): Promise; // resolves when the turn settles + cancel(agentId: string): Promise; + stop(agentId: string): Promise; // close session, then teardown ladder + isRunning(agentId: string): boolean; +} +``` + +`SpawnFn` is `(bin: string, args: string[], opts: { cwd: string; env: NodeJS.ProcessEnv }) => ChildProcessLike` where `ChildProcessLike` has `stdin`, `stdout`, `stderr`, `on("exit")`, `kill()`. Tests inject a spawn that returns an in-process fake agent. + +- [ ] **Step 1: Install the SDK** + +Run: `pnpm --filter @dispatch/server add @agentclientprotocol/sdk@1.4.0` +Expected: `apps/server/package.json` lists it; lockfile updated. + +- [ ] **Step 2: Write the fake agent helper** + +`apps/server/test/helpers/fake-acp-agent.ts`: + +```ts +import { PassThrough } from "node:stream"; +import { Readable, Writable } from "node:stream"; +import { EventEmitter } from "node:events"; +import * as acp from "@agentclientprotocol/sdk"; + +export type FakeTurn = ( + prompt: string, + emit: (update: acp.SessionUpdate) => Promise +) => Promise; + +/** + * An in-process ACP agent wired to a ChildProcess-like object. The driver's + * injected `spawn` returns `child`; the fake agent speaks on the other ends. + */ +export function createFakeAcpAgent( + opts: { turn?: FakeTurn; resumeSessionId?: string } = {} +) { + const toAgent = new PassThrough(); // driver stdin -> agent input + const fromAgent = new PassThrough(); // agent output -> driver stdout + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + stdin: toAgent, + stdout: fromAgent, + stderr, + killed: false, + kill(signal?: string) { + this.killed = true; + queueMicrotask(() => this.emit("exit", null, signal ?? "SIGTERM")); + return true; + }, + }); + const seen = { + newSession: [] as acp.NewSessionRequest[], + prompts: [] as string[], + cancels: 0, + closes: 0, + }; + let sessionCounter = 0; + let connection: acp.AgentSideConnection; + + const agent: acp.Agent = { + async initialize() { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + mcpCapabilities: { http: true }, + sessionCapabilities: { close: {}, resume: {} }, + }, + authMethods: [], + }; + }, + async newSession(params) { + seen.newSession.push(params); + return { sessionId: `sess_${++sessionCounter}`, configOptions: [] }; + }, + async resumeSession(params) { + return { sessionId: params.sessionId, configOptions: [] }; + }, + async prompt(params) { + const text = params.prompt + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); + seen.prompts.push(text); + const emit = (update: acp.SessionUpdate) => + connection.sessionUpdate({ sessionId: params.sessionId, update }); + const stopReason = opts.turn ? await opts.turn(text, emit) : "end_turn"; + return { stopReason }; + }, + async cancel() { + seen.cancels += 1; + }, + async closeSession() { + seen.closes += 1; + return {}; + }, + async authenticate() { + return {}; + }, + }; + + const stream = acp.ndJsonStream( + Writable.toWeb(fromAgent), + Readable.toWeb(toAgent) + ); + connection = new acp.AgentSideConnection(() => agent, stream); + return { child, seen, stderr }; +} +``` + +If the SDK's `Agent` interface names the resume/close methods differently (`resumeSession`, `closeSession`, or `unstable_*`), match the names in `acp.d.ts` for 1.4.0; the driver must call the same ones. + +- [ ] **Step 3: Write the failing driver test** + +`apps/server/test/dsh-driver.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { DshDriver, type DriverEvent } from "../src/agents/dsh/driver.js"; +import { createFakeAcpAgent } from "./helpers/fake-acp-agent.js"; + +const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + +function launch(agentId = "agt_1") { + return { + agentId, + cwd: "/tmp/w", + overlayPath: "/tmp/w/agt_1.patch.yml", + mcp: { url: "http://127.0.0.1:1/api/mcp/agt_1", token: "tok" }, + sessionId: null, + env: { PATH: "/usr/bin" }, + }; +} + +describe("DshDriver", () => { + it("spawns dsh with the acp profile, overlay, cwd, env, and attaches the MCP server", async () => { + const fake = createFakeAcpAgent(); + const spawn = vi.fn(() => fake.child); + const driver = new DshDriver({ + dshBin: "/bin/dsh", + dshHome: "/home/dsh", + spawn, + logger, + }); + const { sessionId } = await driver.start(launch()); + expect(sessionId).toBe("sess_1"); + expect(spawn).toHaveBeenCalledWith( + "/bin/dsh", + ["--profile", "acp", "--patch", "/tmp/w/agt_1.patch.yml"], + expect.objectContaining({ + cwd: "/tmp/w", + env: expect.objectContaining({ + DSH_HOME: "/home/dsh", + DSH_PERMISSION_MODE: "danger-full-access", + PATH: "/usr/bin", + }), + }) + ); + const req = fake.seen.newSession[0]; + expect(req.cwd).toBe("/tmp/w"); + expect(req.mcpServers).toEqual([ + { + type: "http", + name: "dispatch", + url: "http://127.0.0.1:1/api/mcp/agt_1", + headers: [{ name: "Authorization", value: "Bearer tok" }], + }, + ]); + }); + + it("forwards updates and turn boundaries while a prompt runs", async () => { + const fake = createFakeAcpAgent({ + turn: async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }); + return "end_turn"; + }, + }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.prompt("agt_1", "hello"); + expect(fake.seen.prompts).toEqual(["hello"]); + expect(events.map((e) => e.type)).toEqual(["turn", "update", "turn"]); + expect(events[2]).toMatchObject({ + type: "turn", + state: "settled", + stopReason: "end_turn", + }); + }); + + it("resumes when a session id is given", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const { sessionId } = await driver.start({ + ...launch(), + sessionId: "sess_prev", + }); + expect(sessionId).toBe("sess_prev"); + expect(fake.seen.newSession).toHaveLength(0); + }); + + it("stop closes the session and reaps the child", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.stop("agt_1"); + expect(fake.seen.closes).toBe(1); + expect(driver.isRunning("agt_1")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "exit", agentId: "agt_1" }); + }); + + it("a prompt rejected by the agent settles the turn with an error", async () => { + const fake = createFakeAcpAgent({ + turn: async () => { + throw new Error("no API key"); + }, + }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await expect(driver.prompt("agt_1", "x")).rejects.toThrow(/no API key/); + expect(events.at(-1)).toMatchObject({ + type: "turn", + state: "settled", + error: expect.stringContaining("no API key"), + }); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-driver.test.ts` +Expected: FAIL, module not found. + +- [ ] **Step 5: Write the driver** + +`apps/server/src/agents/dsh/driver.ts`: + +```ts +import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; + +export type DriverUpdate = acp.SessionUpdate; + +export type DriverLaunch = { + agentId: string; + cwd: string; + overlayPath: string; + mcp: { url: string; token: string }; + sessionId: string | null; + env: NodeJS.ProcessEnv; +}; + +export type DriverEvent = + | { type: "update"; agentId: string; update: DriverUpdate } + | { + type: "turn"; + agentId: string; + state: "started" | "settled"; + stopReason?: string; + error?: string; + } + | { + type: "exit"; + agentId: string; + code: number | null; + signal: string | null; + stderrTail: string; + }; + +export type DriverListener = (event: DriverEvent) => void; + +type Logger = { + info: (obj: Record, msg: string) => void; + warn: (obj: Record, msg: string) => void; + error: (obj: Record, msg: string) => void; + debug: (obj: Record, msg: string) => void; +}; + +export type ChildProcessLike = Pick< + ChildProcess, + "stdin" | "stdout" | "stderr" | "on" | "kill" | "killed" +>; +export type SpawnFn = ( + bin: string, + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv } +) => ChildProcessLike; + +type Live = { + child: ChildProcessLike; + conn: acp.ClientSideConnection; + sessionId: string; + stderrTail: string[]; + exited: Promise<{ code: number | null; signal: string | null }>; +}; + +const STDERR_TAIL_LINES = 20; +const TEARDOWN_STEP_MS = 1500; + +export class DshDriver { + private readonly live = new Map(); + private readonly listeners = new Set(); + private readonly spawnFn: SpawnFn; + + constructor( + private readonly opts: { + dshBin: string; + dshHome: string; + spawn?: SpawnFn; + logger: Logger; + } + ) { + this.spawnFn = + opts.spawn ?? + ((bin, args, o) => + nodeSpawn(bin, args, { ...o, stdio: ["pipe", "pipe", "pipe"] })); + } + + onEvent(listener: DriverListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(event: DriverEvent): void { + for (const l of this.listeners) { + try { + l(event); + } catch (err) { + this.opts.logger.warn({ err }, "dsh driver listener threw"); + } + } + } + + isRunning(agentId: string): boolean { + return this.live.has(agentId); + } + + async start(launch: DriverLaunch): Promise<{ sessionId: string }> { + if (this.live.has(launch.agentId)) { + throw new Error(`dsh already running for ${launch.agentId}`); + } + const child = this.spawnFn( + this.opts.dshBin, + ["--profile", "acp", "--patch", launch.overlayPath], + { + cwd: launch.cwd, + env: { + ...launch.env, + DSH_HOME: this.opts.dshHome, + DSH_PERMISSION_MODE: "danger-full-access", + }, + } + ); + const stderrTail: string[] = []; + child.stderr?.on("data", (chunk: Buffer) => { + for (const line of chunk.toString("utf8").split("\n")) { + if (!line.trim()) continue; + stderrTail.push(line); + if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift(); + } + }); + const exited = new Promise<{ code: number | null; signal: string | null }>( + (resolve) => { + child.on("exit", (code, signal) => + resolve({ code, signal: signal ?? null }) + ); + } + ); + + const client: acp.Client = { + sessionUpdate: async (params) => { + this.emit({ + type: "update", + agentId: launch.agentId, + update: params.update, + }); + }, + // Permission prompts never fire under danger-full-access; answer allow if one does. + requestPermission: async (params) => { + const allow = + params.options.find((o) => o.kind === "allow_once") ?? + params.options[0]; + return { outcome: { outcome: "selected", optionId: allow.optionId } }; + }, + }; + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin!), + Readable.toWeb(child.stdout!) + ); + const conn = new acp.ClientSideConnection(() => client, stream); + + try { + await conn.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + const mcpServers: acp.McpServer[] = [ + { + type: "http", + name: "dispatch", + url: launch.mcp.url, + headers: [ + { name: "Authorization", value: `Bearer ${launch.mcp.token}` }, + ], + }, + ]; + let sessionId: string; + if (launch.sessionId) { + const res = await conn.resumeSession({ + sessionId: launch.sessionId, + cwd: launch.cwd, + mcpServers, + }); + sessionId = res.sessionId ?? launch.sessionId; + } else { + const res = await conn.newSession({ cwd: launch.cwd, mcpServers }); + sessionId = res.sessionId; + } + const entry: Live = { child, conn, sessionId, stderrTail, exited }; + this.live.set(launch.agentId, entry); + void exited.then(({ code, signal }) => { + if (this.live.get(launch.agentId) === entry) + this.live.delete(launch.agentId); + this.emit({ + type: "exit", + agentId: launch.agentId, + code, + signal, + stderrTail: stderrTail.join("\n"), + }); + }); + return { sessionId }; + } catch (err) { + child.kill("SIGKILL"); + throw new Error( + `dsh start failed: ${(err as Error).message}${stderrTail.length ? `\n${stderrTail.join("\n")}` : ""}` + ); + } + } + + async prompt(agentId: string, text: string): Promise { + const entry = this.require(agentId); + this.emit({ type: "turn", agentId, state: "started" }); + try { + const res = await entry.conn.prompt({ + sessionId: entry.sessionId, + prompt: [{ type: "text", text }], + }); + this.emit({ + type: "turn", + agentId, + state: "settled", + stopReason: res.stopReason, + }); + } catch (err) { + const message = (err as Error).message; + this.emit({ type: "turn", agentId, state: "settled", error: message }); + throw err; + } + } + + async cancel(agentId: string): Promise { + const entry = this.require(agentId); + await entry.conn.cancel({ sessionId: entry.sessionId }); + } + + async stop(agentId: string): Promise { + const entry = this.live.get(agentId); + if (!entry) return; + try { + await Promise.race([ + entry.conn.closeSession({ sessionId: entry.sessionId }), + new Promise((r) => setTimeout(r, TEARDOWN_STEP_MS)), + ]); + } catch (err) { + this.opts.logger.debug( + { err, agentId }, + "dsh session close failed; continuing teardown" + ); + } + entry.child.stdin?.end(); + const exitedIn = (ms: number) => + Promise.race([ + entry.exited.then(() => true), + new Promise((r) => setTimeout(() => r(false), ms)), + ]); + if (!(await exitedIn(TEARDOWN_STEP_MS))) entry.child.kill("SIGTERM"); + if (!(await exitedIn(TEARDOWN_STEP_MS))) entry.child.kill("SIGKILL"); + await entry.exited; + this.live.delete(agentId); + } + + private require(agentId: string): Live { + const entry = this.live.get(agentId); + if (!entry) throw new Error(`dsh is not running for ${agentId}`); + return entry; + } +} +``` + +Check `acp.d.ts` for the exact client-side method names for resume and close in 1.4.0 (`resumeSession`/`closeSession`, possibly prefixed `unstable_`) and for whether `resumeSession` takes `mcpServers`; adjust both the driver and the fake agent together. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-driver.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/package.json pnpm-lock.yaml apps/server/src/agents/dsh/driver.ts apps/server/test/helpers/fake-acp-agent.ts apps/server/test/dsh-driver.test.ts +git commit -m "feat(dsh): ACP driver with one child process per agent + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 6: Stream recorder and usage recorder + +**Files:** + +- Create: `apps/server/src/agents/dsh/stream-recorder.ts` +- Create: `apps/server/src/agents/dsh/usage-recorder.ts` +- Test: `apps/server/test/dsh-stream-recorder.test.ts`, `apps/server/test/dsh-usage-recorder.test.ts` + +**Interfaces:** + +- Consumes: `StreamStore` (Task 2), `DriverEvent` (Task 5). +- Produces: + +```ts +export class StreamRecorder { + constructor(store: StreamStore); + handle(event: DriverEvent): Promise; +} +export class UsageRecorder { + constructor(db: Queryable); + handle( + event: DriverEvent, + ctx: { sessionId: string; model: string } + ): Promise; +} +``` + +`StreamRecorder` payload shapes written to the store: + +- `assistant`: `{ text, streaming }`; chunks append to the latest streaming row; a non-chunk update or a settled turn flips `streaming: false`. +- `thought`: `{ text }`, same accumulation. +- `tool_call` (key = toolCallId): `{ toolKind, title, status, locations, diff, terminalOutput }`; `tool_call_update` merges fields present in the update. +- `status`: `{ message }` for a settled turn with an error, or an exit with non-zero code. + +- [ ] **Step 1: Write the failing recorder test** + +`apps/server/test/dsh-stream-recorder.test.ts`: + +```ts +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { StreamRecorder } from "../src/agents/dsh/stream-recorder.js"; +import { StreamStore } from "../src/agents/dsh/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_rec_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'R', '/tmp', 'running')`, + [A] + ); +}); +afterAll(async () => { + await teardownTestDb(); +}); +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +const chunk = (text: string) => ({ + type: "update" as const, + agentId: A, + update: { + sessionUpdate: "agent_message_chunk" as const, + content: { type: "text" as const, text }, + }, +}); + +describe("StreamRecorder", () => { + it("accumulates chunks into one assistant row and settles it at turn end", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started" }); + await rec.handle(chunk("Hel")); + await rec.handle(chunk("lo")); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = await store.list(A, 10); + expect(rows).toHaveLength(1); + expect(rows[0].payload).toEqual({ text: "Hello", streaming: false }); + }); + + it("starts a new assistant row after a tool call interrupts the text", async () => { + const rec = new StreamRecorder(store); + await rec.handle(chunk("one")); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read x", + kind: "read", + status: "pending", + locations: [{ path: "/w/x" }], + content: [], + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + content: [{ type: "diff", path: "/w/x", oldText: "a", newText: "b" }], + }, + }); + await rec.handle(chunk("two")); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.kind)).toEqual([ + "assistant", + "tool_call", + "assistant", + ]); + expect(rows[0].payload).toEqual({ text: "one", streaming: false }); + expect(rows[1].payload).toMatchObject({ + toolKind: "read", + title: "Read x", + status: "completed", + locations: [{ path: "/w/x" }], + diff: { path: "/w/x", oldText: "a", newText: "b" }, + }); + expect(rows[2].payload).toEqual({ text: "two", streaming: true }); + }); + + it("records a settled error and a crash as status rows", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + error: "no API key", + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 1, + signal: null, + stderrTail: "boom", + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.payload.message)).toEqual([ + "no API key", + "dsh exited with code 1: boom", + ]); + }); +}); +``` + +- [ ] **Step 2: Write the failing usage test** + +`apps/server/test/dsh-usage-recorder.test.ts`: + +```ts +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { UsageRecorder } from "../src/agents/dsh/usage-recorder.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +const A = "agt_usage_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'U', '/tmp', 'running')`, + [A] + ); +}); +afterAll(async () => { + await teardownTestDb(); +}); +beforeEach(async () => { + await pool.query("DELETE FROM agent_token_usage WHERE agent_id = $1", [A]); +}); + +describe("UsageRecorder", () => { + it("upserts cumulative totals per agent, session, and model", async () => { + const rec = new UsageRecorder(pool); + const ctx = { sessionId: "sess_1", model: "openai/gpt-5.2" }; + const usage = (input: number, output: number) => ({ + type: "update" as const, + agentId: A, + update: { + sessionUpdate: "usage_update" as const, + used: input + output, + size: 200000, + usage: { input, output, thought: 0, cache_read: 5, cache_write: 1 }, + }, + }); + await rec.handle(usage(100, 10), ctx); + await rec.handle(usage(250, 40), ctx); + const rows = await pool.query( + `SELECT input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, message_count + FROM agent_token_usage WHERE agent_id = $1 AND session_id = $2 AND model = $3`, + [A, "sess_1", "openai/gpt-5.2"] + ); + expect(rows.rows).toEqual([ + { + input_tokens: 250, + output_tokens: 40, + cache_read_tokens: 5, + cache_creation_tokens: 1, + message_count: 2, + }, + ]); + }); + + it("ignores non-usage updates", async () => { + const rec = new UsageRecorder(pool); + await rec.handle( + { type: "turn", agentId: A, state: "started" }, + { sessionId: "s", model: "m" } + ); + const rows = await pool.query( + `SELECT 1 FROM agent_token_usage WHERE agent_id = $1`, + [A] + ); + expect(rows.rowCount).toBe(0); + }); +}); +``` + +Read the `Usage` type in the SDK's `types.gen.d.ts` (search `export type Usage`) and match the field names exactly in both the test and the recorder. The column names above come from `UPSERT_SQL` in `token-harvester.ts`; check the table for `NOT NULL` columns (`session_start`, `session_end`) and supply `NOW()` for them. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-stream-recorder.test.ts test/dsh-usage-recorder.test.ts` +Expected: FAIL, modules not found. + +- [ ] **Step 4: Write the stream recorder** + +`apps/server/src/agents/dsh/stream-recorder.ts`: + +```ts +import type { DriverEvent, DriverUpdate } from "./driver.js"; +import type { StreamEventRow, StreamStore } from "./stream-store.js"; + +type OpenText = { row: StreamEventRow; text: string }; + +type ToolPayload = { + toolKind: string; + title: string; + status: "pending" | "in_progress" | "completed" | "failed"; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; +}; + +function textOf(content: { type: string; text?: string } | undefined): string { + return content && content.type === "text" && typeof content.text === "string" + ? content.text + : ""; +} + +function projectToolContent(content: unknown[] | undefined): { + diff: ToolPayload["diff"]; + terminalOutput: string | null; +} { + let diff: ToolPayload["diff"] = null; + let terminalOutput: string | null = null; + for (const item of content ?? []) { + const c = item as { + type: string; + path?: string; + oldText?: string | null; + newText?: string; + content?: { type: string; text?: string }; + }; + if (c.type === "diff" && c.path && typeof c.newText === "string") { + diff = { path: c.path, oldText: c.oldText ?? null, newText: c.newText }; + } else if (c.type === "content" && c.content?.type === "text") { + terminalOutput = (terminalOutput ?? "") + (c.content.text ?? ""); + } + } + return { diff, terminalOutput }; +} + +/** Folds driver events into agent_stream_events rows. One instance per server; state is per agent. */ +export class StreamRecorder { + private readonly open = new Map< + string, + { assistant?: OpenText; thought?: OpenText } + >(); + + constructor(private readonly store: StreamStore) {} + + async handle(event: DriverEvent): Promise { + if (event.type === "update") + return this.handleUpdate(event.agentId, event.update); + if (event.type === "turn") { + if (event.state === "settled") { + await this.closeText(event.agentId); + if (event.error) + await this.store.append(event.agentId, "status", { + message: event.error, + }); + } + return; + } + if (event.type === "exit") { + await this.closeText(event.agentId); + if (event.code !== 0) { + const detail = event.stderrTail ? `: ${event.stderrTail}` : ""; + await this.store.append(event.agentId, "status", { + message: `dsh exited with ${event.code === null ? `signal ${event.signal}` : `code ${event.code}`}${detail}`, + }); + } + } + } + + private async handleUpdate( + agentId: string, + update: DriverUpdate + ): Promise { + switch (update.sessionUpdate) { + case "agent_message_chunk": + return this.appendText(agentId, "assistant", textOf(update.content)); + case "agent_thought_chunk": + return this.appendText(agentId, "thought", textOf(update.content)); + case "tool_call": { + await this.closeText(agentId); + const { diff, terminalOutput } = projectToolContent(update.content); + const payload: ToolPayload = { + toolKind: update.kind ?? "other", + title: update.title, + status: update.status ?? "pending", + locations: (update.locations ?? []).map((l) => ({ + path: l.path, + ...(l.line != null ? { line: l.line } : {}), + })), + diff, + terminalOutput, + }; + await this.store.upsertByKey( + agentId, + "tool_call", + update.toolCallId, + payload + ); + return; + } + case "tool_call_update": { + const existing = await this.store.upsertByKey( + agentId, + "tool_call", + update.toolCallId, + {} + ); + const prev = existing.payload as Partial; + const projected = update.content + ? projectToolContent(update.content) + : null; + const next: ToolPayload = { + toolKind: update.kind ?? prev.toolKind ?? "other", + title: update.title ?? prev.title ?? "", + status: update.status ?? prev.status ?? "pending", + locations: update.locations + ? update.locations.map((l) => ({ + path: l.path, + ...(l.line != null ? { line: l.line } : {}), + })) + : (prev.locations ?? []), + diff: projected?.diff ?? prev.diff ?? null, + terminalOutput: + projected?.terminalOutput ?? prev.terminalOutput ?? null, + }; + await this.store.updatePayload(existing.id, next); + return; + } + default: + return; + } + } + + private async appendText( + agentId: string, + kind: "assistant" | "thought", + delta: string + ): Promise { + if (!delta) return; + const state = this.open.get(agentId) ?? {}; + const other = kind === "assistant" ? "thought" : "assistant"; + if (state[other]) await this.closeText(agentId, other); + let current = state[kind]; + if (!current) { + const row = await this.store.append( + agentId, + kind, + kind === "assistant" + ? { text: delta, streaming: true } + : { text: delta } + ); + current = { row, text: delta }; + } else { + current.text += delta; + await this.store.updatePayload( + current.row.id, + kind === "assistant" + ? { text: current.text, streaming: true } + : { text: current.text } + ); + } + state[kind] = current; + this.open.set(agentId, state); + } + + private async closeText( + agentId: string, + only?: "assistant" | "thought" + ): Promise { + const state = this.open.get(agentId); + if (!state) return; + for (const kind of ["assistant", "thought"] as const) { + if (only && kind !== only) continue; + const current = state[kind]; + if (!current) continue; + await this.store.updatePayload( + current.row.id, + kind === "assistant" + ? { text: current.text, streaming: false } + : { text: current.text } + ); + delete state[kind]; + } + } +} +``` + +- [ ] **Step 5: Write the usage recorder** + +`apps/server/src/agents/dsh/usage-recorder.ts`: + +```ts +import type { Queryable } from "../../chat/feed.js"; +import type { DriverEvent } from "./driver.js"; + +const UPSERT = `INSERT INTO agent_token_usage + (agent_id, session_id, model, input_tokens, cache_creation_tokens, cache_read_tokens, + output_tokens, message_count, session_start, session_end) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW(), NOW()) + ON CONFLICT (agent_id, session_id, model) + DO UPDATE SET + input_tokens = EXCLUDED.input_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + output_tokens = EXCLUDED.output_tokens, + message_count = agent_token_usage.message_count + 1, + session_end = NOW(), + harvested_at = NOW()`; + +/** Writes ACP usage_update totals (cumulative per session) into agent_token_usage. */ +export class UsageRecorder { + constructor(private readonly db: Queryable) {} + + async handle( + event: DriverEvent, + ctx: { sessionId: string; model: string } + ): Promise { + if ( + event.type !== "update" || + event.update.sessionUpdate !== "usage_update" + ) + return; + const u = event.update.usage; + if (!u) return; + await this.db.query(UPSERT, [ + event.agentId, + ctx.sessionId, + ctx.model, + u.input ?? 0, + u.cache_write ?? 0, + u.cache_read ?? 0, + u.output ?? 0, + ]); + } +} +``` + +Match the `Usage` field names to the SDK type. If `agent_token_usage` lacks `harvested_at` or has other required columns, read migration `0018`-ish for that table (`grep -l agent_token_usage apps/server/src/db/migrations/*`) and adjust. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-stream-recorder.test.ts test/dsh-usage-recorder.test.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/agents/dsh/stream-recorder.ts apps/server/src/agents/dsh/usage-recorder.ts apps/server/test/dsh-stream-recorder.test.ts apps/server/test/dsh-usage-recorder.test.ts +git commit -m "feat(dsh): fold ACP updates into stream rows and token usage + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 7: Supervisor and manager wiring + +**Files:** + +- Create: `apps/server/src/agents/dsh/supervisor.ts` +- Modify: `apps/server/src/agents/manager.ts` (constructor, `completeSetup`, `stopAgent`, `startAgent`, archive/delete paths) +- Modify: `apps/server/src/agents/activity-monitor.ts` (skip dsh) +- Test: `apps/server/test/dsh-supervisor.test.ts` + +**Interfaces:** + +- Consumes: `DshDriver`, `StreamRecorder`, `UsageRecorder`, `writeOverlay`, `dispatchMcpUrl` and `createAgentMcpToken` from `apps/server/src/agents/tmux/mcp-url.ts` and `apps/server/src/server/auth.ts` (check exact export names with grep), `buildLaunchGuidance` or whatever `command-builder.ts` uses to produce `launchGuidance` (grep `launchGuidance` in that file and reuse the same function). +- Produces: + +```ts +export type SupervisorDeps = { + pool: Pool; + config: AppConfig; + logger: Logger; + driver?: DshDriver; // injectable for tests + getAgent: (id: string) => Promise; + setCliSessionId: (id: string, sessionId: string) => Promise; + setLatestEvent: ( + id: string, + input: { type: AgentLatestEventType; message: string } + ) => Promise; + publishChat: (agentId: string) => void; // ChatService.publishChanged + personaPromptFor: (agent: AgentRecord) => Promise; // launch guidance + persona/personality +}; +export class DshSupervisor { + constructor(deps: SupervisorDeps); + start(agentId: string): Promise; // builds overlay, starts driver, stores session id, sets idle + prompt(agentId: string, text: string): Promise; // sets working, resolves when settled, sets idle + stop(agentId: string): Promise; + isRunning(agentId: string): boolean; +} +``` + +- [ ] **Step 1: Write the failing test** + +`apps/server/test/dsh-supervisor.test.ts` (uses the fake ACP agent and a fake pool): + +```ts +import { describe, expect, it, vi } from "vitest"; +import { DshDriver } from "../src/agents/dsh/driver.js"; +import { DshSupervisor } from "../src/agents/dsh/supervisor.js"; +import { createFakeAcpAgent } from "./helpers/fake-acp-agent.js"; + +const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + +function build(turn?: Parameters[0]["turn"]) { + const fake = createFakeAcpAgent({ turn }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/tmp/dsh-home-test", + spawn: () => fake.child, + logger, + }); + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })); + const events: { type: string; message: string }[] = []; + const deps = { + pool: { query } as never, + config: { + dshBin: "dsh", + dshHome: "/tmp/dsh-home-test", + host: "127.0.0.1", + port: 1, + authToken: "secret", + scheme: "http", + } as never, + logger, + driver, + getAgent: vi.fn(async (id: string) => ({ + id, + type: "dsh", + cwd: "/tmp/w", + model: "openai/gpt-5.2", + cliSessionId: null, + })) as never, + setCliSessionId: vi.fn(async () => {}), + setLatestEvent: vi.fn( + async (_id: string, input: { type: string; message: string }) => { + events.push(input); + } + ), + publishChat: vi.fn(), + personaPromptFor: vi.fn(async () => "PERSONA"), + }; + return { fake, deps, events, sup: new DshSupervisor(deps) }; +} + +describe("DshSupervisor", () => { + it("start writes the overlay, records the session id, and marks idle", async () => { + const { sup, deps, fake, events } = build(); + await sup.start("agt_1"); + expect(deps.setCliSessionId).toHaveBeenCalledWith("agt_1", "sess_1"); + expect(fake.seen.newSession[0].cwd).toBe("/tmp/w"); + expect(events.at(-1)).toEqual({ + type: "idle", + message: "dsh session started.", + }); + }); + + it("prompt marks working, then idle when the turn settles", async () => { + const { sup, events } = build(async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "ok" }, + }); + return "end_turn"; + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.map((e) => e.type)).toEqual(["idle", "working", "idle"]); + }); + + it("prompt failure surfaces as idle with the error message", async () => { + const { sup, events } = build(async () => { + throw new Error("no API key for provider route"); + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.at(-1)).toMatchObject({ + type: "idle", + message: expect.stringContaining("no API key"), + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-supervisor.test.ts` +Expected: FAIL, module not found. + +- [ ] **Step 3: Write the supervisor** + +`apps/server/src/agents/dsh/supervisor.ts`: + +```ts +import path from "node:path"; +import type { Pool } from "pg"; +import type { AgentLatestEventType, AgentRecord } from "@dispatch/shared"; + +import type { AppConfig } from "../../config.js"; +import { createAgentMcpToken } from "../../server/auth.js"; +import { dispatchMcpUrl } from "../tmux/mcp-url.js"; +import { DshDriver, type DriverEvent } from "./driver.js"; +import { writeOverlay } from "./overlay.js"; +import { StreamRecorder } from "./stream-recorder.js"; +import { StreamStore } from "./stream-store.js"; +import { UsageRecorder } from "./usage-recorder.js"; + +type Logger = { + info: (obj: Record, msg: string) => void; + warn: (obj: Record, msg: string) => void; + error: (obj: Record, msg: string) => void; + debug: (obj: Record, msg: string) => void; +}; + +export type SupervisorDeps = { + pool: Pool; + config: AppConfig; + logger: Logger; + driver?: DshDriver; + getAgent: (id: string) => Promise; + setCliSessionId: (id: string, sessionId: string) => Promise; + setLatestEvent: ( + id: string, + input: { type: AgentLatestEventType; message: string } + ) => Promise; + publishChat: (agentId: string) => void; + personaPromptFor: (agent: AgentRecord) => Promise; +}; + +const PASSTHROUGH_ENV = [ + "PATH", + "HOME", + "SHELL", + "LANG", + "TMPDIR", + "DEEPSEEK_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", +]; + +export class DshSupervisor { + private readonly driver: DshDriver; + private readonly streams: StreamRecorder; + private readonly usage: UsageRecorder; + private readonly context = new Map< + string, + { sessionId: string; model: string } + >(); + + constructor(private readonly deps: SupervisorDeps) { + this.driver = + deps.driver ?? + new DshDriver({ + dshBin: deps.config.dshBin, + dshHome: deps.config.dshHome, + logger: deps.logger, + }); + this.streams = new StreamRecorder(new StreamStore(deps.pool)); + this.usage = new UsageRecorder(deps.pool); + this.driver.onEvent((event) => void this.onEvent(event)); + } + + isRunning(agentId: string): boolean { + return this.driver.isRunning(agentId); + } + + async start(agentId: string): Promise { + const agent = await this.deps.getAgent(agentId); + if (!agent || agent.type !== "dsh") + throw new Error(`${agentId} is not a dsh agent`); + const overlayDir = path.join(this.deps.config.dshHome, "overlays"); + const overlayPath = await writeOverlay(overlayDir, agentId, { + model: agent.model ?? null, + persona: await this.deps.personaPromptFor(agent), + }); + const env: NodeJS.ProcessEnv = {}; + for (const key of PASSTHROUGH_ENV) + if (process.env[key]) env[key] = process.env[key]; + env.DISPATCH_AGENT_ID = agentId; + const { sessionId } = await this.driver.start({ + agentId, + cwd: agent.cwd, + overlayPath, + mcp: { + url: dispatchMcpUrl(this.deps.config, agentId), + token: createAgentMcpToken(this.deps.config.authToken, agentId), + }, + sessionId: agent.cliSessionId ?? null, + env, + }); + this.context.set(agentId, { sessionId, model: agent.model ?? "default" }); + await this.deps.setCliSessionId(agentId, sessionId); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: "dsh session started.", + }); + } + + async prompt(agentId: string, text: string): Promise { + await this.deps.setLatestEvent(agentId, { + type: "working", + message: "Working on the latest message.", + }); + try { + await this.driver.prompt(agentId, text); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: "Turn finished.", + }); + } catch (err) { + const message = (err as Error).message; + this.deps.logger.warn({ err, agentId }, "dsh prompt failed"); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: `Turn failed: ${message}`.slice(0, 200), + }); + } + } + + async stop(agentId: string): Promise { + await this.driver.stop(agentId); + this.context.delete(agentId); + } + + private async onEvent(event: DriverEvent): Promise { + try { + await this.streams.handle(event); + const ctx = this.context.get(event.agentId); + if (ctx) await this.usage.handle(event, ctx); + this.deps.publishChat(event.agentId); + if (event.type === "exit" && event.code !== 0) { + await this.deps.setLatestEvent(event.agentId, { + type: "blocked", + message: `dsh exited (${event.code ?? event.signal}).`, + }); + } + } catch (err) { + this.deps.logger.warn( + { err, agentId: event.agentId }, + "dsh event handling failed" + ); + } + } +} +``` + +Check the signature of `dispatchMcpUrl` (it may take `(config, agentId, jobRunId?)`) and of `createAgentMcpToken` in `apps/server/src/server/auth.ts`; import from wherever `command-builder.ts` imports them. + +- [ ] **Step 4: Wire the manager** + +In `apps/server/src/agents/manager.ts`: + +1. Import `DshSupervisor` and add a field `private dshSupervisor: DshSupervisor | null = null;`. +2. Add `attachDshSupervisor(sup: DshSupervisor): void { this.dshSupervisor = sup; }` beside `attachDiffStatsRefresher`, and `getDshSupervisor(): DshSupervisor | null`. +3. Add a public `async setCliSessionId(id: string, sessionId: string)` that runs `UPDATE agents SET cli_session_id = $2, updated_at = NOW() WHERE id = $1` (reuse `claimCliSessionId` if it already does this). +4. In `completeSetup`, after `populateGitContext` and before the setup-script unlink: + +```ts +if (agent.type === "dsh" && this.dshSupervisor) { + try { + await this.dshSupervisor.start(id); + } catch (error) { + const message = errorMessage(error); + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `dsh failed to start: ${message}`.slice(0, 200), + }); + throw new AgentError(`dsh failed to start: ${message}`, 500); + } +} else { + await this.setSystemLatestEvent( + id, + agent.type === "terminal" + ? { type: "idle", message: "Terminal session started." } + : { type: "idle", message: "Session started." } + ); +} +``` + +(Replace the existing `setSystemLatestEvent` call with this block.) + +5. In `stopAgent`, before `runtime.stopSession`, add `if (agent.type === "dsh") await this.dshSupervisor?.stop(id);`. Do the same in the archive and delete paths where the tmux session is killed (grep `killSession(` in the manager and add the supervisor stop beside each). +6. In `startAgent` (the restart path), after `runtime.launch({ payload: agent-command })` succeeds, add `if (agent.type === "dsh") await this.dshSupervisor?.start(id);` so a restart resumes the stored session id. + +In the server composition root where `AgentManager` and `ChatService` are constructed (grep `attachDiffStatsRefresher(` outside the manager to find it), construct the supervisor: + +```ts +const dshSupervisor = new DshSupervisor({ + pool, + config, + logger: app.log, + getAgent: (id) => agentManager.getAgent(id), + setCliSessionId: (id, sid) => agentManager.setCliSessionId(id, sid), + setLatestEvent: (id, input) => + agentManager.upsertLatestEvent(id, input).then(() => undefined), + publishChat: (id) => chatService.publishChanged(id), + personaPromptFor: async (agent) => buildDshPersona(agent), +}); +agentManager.attachDshSupervisor(dshSupervisor); +``` + +`buildDshPersona` lives in `apps/server/src/agents/dsh/persona.ts` (create it in this task): it returns the same `launchGuidance` string `command-builder.ts` builds for CLI agents (extract that builder into an exported function if it is inline), followed by two newlines and the agent's persona prompt or active personality prompt when present. Find how `command-builder.ts` receives `personalityPrompt` and `appendedSystemPrompt` and source them the same way (the persona launch path stores the persona prompt in `agentArgs` as `--append-system-prompt`; parse it out with `normalizeAgentArgsForType("claude", agent.agentArgs).appendedSystemPrompt`). + +7. In `apps/server/src/agents/activity-monitor.ts`, skip agents whose `type === "dsh"` in the poll loop (the supervisor owns their working/idle). + +- [ ] **Step 5: Run tests and type check** + +Run: `pnpm --filter @dispatch/server exec vitest run test/dsh-supervisor.test.ts test/agent-lifecycle-runtime.test.ts test/agent-startup.test.ts && pnpm run check` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/src/agents/dsh apps/server/src/agents/manager.ts apps/server/src/agents/activity-monitor.ts apps/server/src/server apps/server/src/index.ts apps/server/test/dsh-supervisor.test.ts +git commit -m "feat(dsh): supervisor starts, prompts, and stops dsh from the agent lifecycle + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 8: Route prompts to dsh + +**Files:** + +- Modify: `apps/server/src/server/agent-prompts.ts:26-75` +- Test: `apps/server/test/agent-prompts.test.ts` + +**Interfaces:** + +- Consumes: `agentManager.getDshSupervisor()` and `agentManager.getAgent(id)`. +- Produces: unchanged `EnqueueAgentPrompt` signature. For dsh agents `held` is always `false` and `delivery` resolves when the prompt is accepted by the driver (not when the turn settles), so chat and message rows flip to `delivered: true` promptly. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/server/test/agent-prompts.test.ts`, extending `build()` so `agentManager` also has `getAgent` and `getDshSupervisor`: + +```ts +it("routes dsh agents to the supervisor instead of the pane", async () => { + const prompt = vi.fn(async () => {}); + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getAgent = vi.fn(async () => ({ + id: "agt_d", + type: "dsh", + })) as never; + agentManager.getDshSupervisor = vi.fn(() => ({ + isRunning: () => true, + prompt, + })) as never; + const { held, delivery } = await enqueueAgentPrompt("agt_d", "hello dsh"); + expect(held).toBe(false); + await delivery; + expect(prompt).toHaveBeenCalledWith("agt_d", "hello dsh"); + expect(sendCommand).not.toHaveBeenCalled(); +}); + +it("fails loudly when the dsh process is not running", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getAgent = vi.fn(async () => ({ + id: "agt_d", + type: "dsh", + })) as never; + agentManager.getDshSupervisor = vi.fn(() => ({ + isRunning: () => false, + prompt: vi.fn(), + })) as never; + await expect(enqueueAgentPrompt("agt_d", "x")).rejects.toThrow( + /dsh is not running/ + ); +}); +``` + +In `build()`, give `agentManager` default `getAgent: vi.fn(async () => ({ id: "agt_1", type: "claude" }))` and `getDshSupervisor: vi.fn(() => null)` so the existing tests keep passing. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/server exec vitest run test/agent-prompts.test.ts` +Expected: FAIL, the dsh test still hits the pane path. + +- [ ] **Step 3: Add the branch** + +In `createPromptInjector`'s `enqueueAgentPrompt`, before `getTerminalAccess`: + +```ts +const agent = await agentManager.getAgent(agentId); +if (agent?.type === "dsh") { + const supervisor = agentManager.getDshSupervisor(); + if (!supervisor || !supervisor.isRunning(agentId)) { + throw new Error( + "dsh is not running for this agent — prompt cannot be delivered." + ); + } + // The turn runs in the background; delivery means "accepted", matching + // what pane injection promises for CLI agents. + const turn = supervisor.prompt(agentId, prompt); + turn.catch((error) => + appLog.warn({ err: error, agentId }, "dsh turn failed") + ); + return { held: false, delivery: Promise.resolve() }; +} +``` + +Update the `AgentManager` type expectation in this file if it uses a narrowed structural type. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @dispatch/server exec vitest run test/agent-prompts.test.ts && pnpm run check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/server/agent-prompts.ts apps/server/test/agent-prompts.test.ts +git commit -m "feat(dsh): deliver prompts and messages to dsh over ACP instead of the pane + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 9: Render assistant and activity entries in the Chat tab + +**Files:** + +- Modify: `apps/web/src/components/app/chat/chat-entries.tsx` (append two components) +- Modify: `apps/web/src/components/app/chat/chat-feed.tsx:170-186` (`authorKey`) and `:356-400` (render switch) +- Modify: `apps/web/src/components/app/agent-pane.tsx` (default tab Chat for dsh; grep `Console` there) +- Test: `apps/web/src/components/app/chat/chat-feed.test.tsx` + +**Interfaces:** + +- Consumes: `ChatAssistantEntry`, `ChatActivityEntry` from `@dispatch/shared`. +- Produces: `AssistantEntryView`, `ActivityEntryView` exported from `chat-entries.tsx` with the same `{ entry, grouped, rule, ctx }` props shape `ReviewEntryView` takes. + +- [ ] **Step 1: Write the failing test** + +Append to `apps/web/src/components/app/chat/chat-feed.test.tsx`, following that file's existing render helper: + +```tsx +it("renders assistant text and a tool activity row", () => { + renderFeed([ + { + type: "assistant", + id: "stream:1", + text: "I will read the file.", + streaming: false, + at: "2026-09-04T10:00:00Z", + }, + { + type: "activity", + id: "stream:2", + toolKind: "read", + title: "Read README.md", + status: "completed", + locations: [{ path: "/w/README.md" }], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:01Z", + }, + ]); + expect(screen.getByText("I will read the file.")).toBeInTheDocument(); + expect(screen.getByText("Read README.md")).toBeInTheDocument(); + expect(screen.getByLabelText("completed")).toBeInTheDocument(); +}); + +it("shows a streaming indicator while an assistant message is open", () => { + renderFeed([ + { + type: "assistant", + id: "stream:1", + text: "Thinking", + streaming: true, + at: "2026-09-04T10:00:00Z", + }, + ]); + expect(screen.getByLabelText("streaming")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @dispatch/web exec vitest run src/components/app/chat/chat-feed.test.tsx` +Expected: FAIL. TypeScript rejects the entry types in the switch, or the text is not found. + +- [ ] **Step 3: Add the views** + +Append to `chat-entries.tsx` (reuse `Post` and whatever markdown renderer `ChatMessageView` uses; grep `Markdown` in that file): + +```tsx +export function AssistantEntryView({ + entry, + grouped, + rule, + ctx, +}: { + entry: ChatAssistantEntry; + grouped: boolean; + rule: boolean; + ctx: FeedContext; +}): JSX.Element { + return ( + + + {entry.streaming ? ( + + ) : null} + + ); +} + +const ACTIVITY_STATUS_CLASS: Record = { + pending: "bg-muted-foreground/40", + in_progress: "bg-status-working", + completed: "bg-status-done", + failed: "bg-status-blocked", +}; + +export function ActivityEntryView({ + entry, + grouped, + rule, +}: { + entry: ChatActivityEntry; + grouped: boolean; + rule: boolean; + ctx: FeedContext; +}): JSX.Element { + const [open, setOpen] = useState(false); + const expandable = entry.diff !== null || entry.terminalOutput !== null; + return ( +
+ + {open && entry.diff ? ( +
+          {renderUnifiedDiff(entry.diff.oldText ?? "", entry.diff.newText)}
+        
+ ) : null} + {open && entry.terminalOutput ? ( +
+          {entry.terminalOutput}
+        
+ ) : null} +
+ ); +} + +function renderUnifiedDiff(oldText: string, newText: string): string { + const a = oldText.split("\n"); + const b = newText.split("\n"); + const out: string[] = []; + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i++) { + if (a[i] === b[i]) out.push(` ${a[i] ?? ""}`); + else { + if (i < a.length) out.push(`- ${a[i]}`); + if (i < b.length) out.push(`+ ${b[i]}`); + } + } + return out.join("\n"); +} +``` + +Replace `ChatMarkdown` with the actual markdown component name used by `ChatMessageView`, and `ctx.agentName` with however `chatMessageAuthor` reads the agent's display name from `FeedContext`. Import `useState` and `cn` if not already imported. + +- [ ] **Step 4: Wire the feed** + +In `chat-feed.tsx` `authorKey`, add: + +```ts + case "assistant": + return "agent"; + case "activity": + return "agent"; +``` + +In the render switch add: + +```tsx + case "assistant": + return ; + case "activity": + return ; +``` + +Anywhere else the file narrows on `entry.type` for grouping or read receipts (lines ~240-275), treat `assistant` like an agent chat message and `activity` like `status` for unread counting. + +In `agent-pane.tsx`, where the initial tab is chosen, default to the Chat tab when `agent.type === "dsh"` (keep the persisted choice if the user changed it). + +- [ ] **Step 5: Run tests, type check, and finalize web** + +Run: `pnpm --filter @dispatch/web exec vitest run src/components/app/chat && pnpm run check && pnpm run finalize:web` +Expected: PASS, build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src +git commit -m "feat(dsh): render assistant text and tool activity in the Chat tab + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 10: End-to-end with a fake `dsh` shim + +**Files:** + +- Create: `e2e/fixtures/fake-dsh.mjs` +- Modify: `scripts/e2e-isolated.sh` (export `DISPATCH_DSH_BIN` pointing at the shim; a tmux run is required for setup to complete, so this spec sets `E2E_AGENT_RUNTIME=tmux` in its own describe and skips when tmux is missing) +- Create: `e2e/dsh-agent.spec.ts` + +**Interfaces:** + +- Consumes: the ACP agent side of `@agentclientprotocol/sdk` (installed under `apps/server`; the shim resolves it via `createRequire` from `apps/server/package.json`). + +- [ ] **Step 1: Write the shim** + +`e2e/fixtures/fake-dsh.mjs`: + +```js +#!/usr/bin/env node +// Fake `dsh` for E2E: speaks ACP on stdio, ignores --profile/--patch, and +// scripts one turn: a tool call plus an assistant message echoing the prompt. +import { createRequire } from "node:module"; +import { Readable, Writable } from "node:stream"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire( + path.resolve(here, "../../apps/server/package.json") +); +const acp = require("@agentclientprotocol/sdk"); + +let conn; +const agent = { + async initialize() { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentInfo: { name: "fake-dsh", version: "0.0.0" }, + agentCapabilities: { + mcpCapabilities: { http: true }, + sessionCapabilities: { close: {}, resume: {} }, + }, + authMethods: [], + }; + }, + async authenticate() { + return {}; + }, + async newSession(params) { + process.stderr.write( + `fake-dsh newSession mcp=${JSON.stringify(params.mcpServers?.map((s) => s.name))}\n` + ); + return { sessionId: `fake_${Date.now()}`, configOptions: [] }; + }, + async resumeSession(params) { + return { sessionId: params.sessionId, configOptions: [] }; + }, + async prompt(params) { + const text = params.prompt + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); + const emit = (update) => + conn.sessionUpdate({ sessionId: params.sessionId, update }); + await emit({ + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read README.md", + kind: "read", + status: "in_progress", + locations: [{ path: `${params.cwd ?? process.cwd()}/README.md` }], + content: [], + }); + await emit({ + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + }); + for (const piece of ["You said: ", text]) { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: piece }, + }); + } + await emit({ + sessionUpdate: "usage_update", + used: 120, + size: 200000, + usage: { + input: 100, + output: 20, + thought: 0, + cache_read: 0, + cache_write: 0, + }, + }); + return { stopReason: "end_turn" }; + }, + async cancel() {}, + async closeSession() { + return {}; + }, +}; + +const stream = acp.ndJsonStream( + Writable.toWeb(process.stdout), + Readable.toWeb(process.stdin) +); +conn = new acp.AgentSideConnection(() => agent, stream); +process.stdin.on("end", () => process.exit(0)); +``` + +Run `chmod +x e2e/fixtures/fake-dsh.mjs`. + +In `scripts/e2e-isolated.sh`, next to the other exports: + +```sh +export DISPATCH_DSH_BIN="${DISPATCH_DSH_BIN:-$PWD/e2e/fixtures/fake-dsh.mjs}" +export DISPATCH_DSH_HOME="/tmp/dispatch-dsh-home-${RUN_ID}" +``` + +- [ ] **Step 2: Write the spec** + +`e2e/dsh-agent.spec.ts`: + +```ts +import { test, expect } from "@playwright/test"; +import { + cleanupE2EAgents, + createAgentViaAPI, + loadApp, + setEnabledAgentTypesViaAPI, +} from "./helpers"; + +const tmux = process.env.DISPATCH_AGENT_RUNTIME === "tmux"; + +test.describe("dsh agent", () => { + test.skip(!tmux, "dsh setup completes through the tmux setup script"); + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("appears in the type picker, streams into the Chat tab, and accepts a chat message", async ({ + page, + request, + }) => { + await setEnabledAgentTypesViaAPI(request, ["claude", "dsh"]); + const agent = await createAgentViaAPI(request, { + type: "dsh", + cwd: process.cwd(), + useWorktree: true, + }); + await loadApp(page); + await page.getByRole("link", { name: agent.name }).click(); + await expect(page.getByRole("tab", { name: "Chat" })).toHaveAttribute( + "aria-selected", + "true" + ); + + const composer = page.getByPlaceholder(/message/i); + await composer.fill("hello harness"); + await composer.press("Enter"); + + await expect(page.getByText("Read README.md")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText("You said: hello harness")).toBeVisible({ + timeout: 20_000, + }); + + const res = await request.get(`/api/v1/agents/${agent.id}`, { + headers: { + Authorization: `Bearer ${process.env.AUTH_TOKEN ?? "dev-token"}`, + }, + }); + const body = (await res.json()) as { + agent: { latestEvent: { type: string } | null }; + }; + expect(body.agent.latestEvent?.type).toBe("idle"); + }); +}); +``` + +Check `e2e/helpers.ts` for the exact selectors the chat-surface spec uses for the composer and tab, and the base URL the `request` fixture needs; copy them. + +- [ ] **Step 3: Run the E2E** + +Run: `E2E_AGENT_RUNTIME=tmux pnpm run test:e2e -- e2e/dsh-agent.spec.ts` +Expected: PASS. If the chat surface is behind the `chat_surface_enabled` flag, enable it through the same API the chat-surface spec uses before loading the app. + +- [ ] **Step 4: Run the full suites** + +Run: `pnpm run check && pnpm run test && pnpm run test:e2e` +Expected: PASS. Note any pre-existing flake by name. + +- [ ] **Step 5: Commit** + +```bash +git add e2e scripts/e2e-isolated.sh +git commit -m "test(dsh): end-to-end against a fake ACP dsh shim + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 11: Live validation and hand-off + +**Files:** + +- Modify: `docs/superpowers/specs/2026-09-04-dsh-harness-design.md` (status line only) + +- [ ] **Step 1: Start an isolated stack** + +Use the `repo_dev_up` MCP tool. Export `DISPATCH_DSH_BIN` to the real `dsh` (install with `pnpm add -g @deepseek-ai/dsh` if absent) and one of `DEEPSEEK_API_KEY` or `OPENAI_API_KEY` in the environment the stack inherits. + +- [ ] **Step 2: Launch a dsh agent from the UI** + +Enable the dsh type in Settings, create an agent with a worktree, pick a model, and send "List the top-level files and tell me what this repo is." Confirm: status goes working then idle without the agent calling `dispatch_event`; assistant text and activity rows appear; the token panel shows usage for the session; a cross-agent message from a Claude agent lands as a prompt. + +- [ ] **Step 3: Screenshot and publish** + +Capture the Chat tab with Playwright and publish with `dispatch_share_file`. Call `browser_close`. + +- [ ] **Step 4: Update the spec status and commit** + +Change the spec's status line to `Status: prototype implemented (see docs/superpowers/plans/2026-09-04-dsh-harness.md); live-validated against .` and commit: + +```bash +git add docs/superpowers/specs/2026-09-04-dsh-harness-design.md +git commit -m "docs(dsh): record prototype validation + +Co-Authored-By: Claude Fable 5.1 " +``` + +Leave the dev stack running and report its URLs and the `repo_dev_down` cleanup command. From eb9bd2a556b92da4922e464b420629427b8f6c55 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:06:53 -0700 Subject: [PATCH 003/254] feat(dsh): register the dsh agent type, config, and model catalog Adds dsh to AGENT_TYPES and CLI_AGENT_TYPES, dshBin/dshHome config, a provider-qualified model catalog entry, a login-shell launch command (the ACP driver owns the harness process), a token-harvester early return, and the web label, description, and icon. Co-Authored-By: Claude Fable 5.1 --- .../server/src/agents/tmux/command-builder.ts | 11 +++++-- apps/server/src/agents/token-harvester.ts | 2 ++ apps/server/src/config.ts | 9 ++++++ apps/server/src/shared/agent-models.ts | 9 ++++++ apps/server/test/agent-models.test.ts | 9 ++++++ apps/server/test/db/agent-manager.test.ts | 2 ++ apps/server/test/mcp-url.test.ts | 2 ++ apps/server/test/tmux-command-builder.test.ts | 19 ++++++++++++ .../src/components/app/agent-type-icon.tsx | 30 ++++++++++++++++--- .../components/app/agent-type-settings.tsx | 1 + apps/web/src/lib/agent-types.ts | 1 + docs/agent-model-catalog.md | 9 ++++++ packages/shared/src/agent-types.ts | 2 ++ 13 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 35bd95e73..d6c7e9f1b 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -19,12 +19,16 @@ import { agentIdFromSessionName } from "./session-name.js"; // directly) doesn't redeclare the mapping. export const CLI_BY_AGENT_TYPE: Record< Exclude, - keyof Pick + keyof Pick< + AppConfig, + "codexBin" | "claudeBin" | "opencodeBin" | "cursorBin" | "dshBin" + > > = { codex: "codexBin", claude: "claudeBin", opencode: "opencodeBin", cursor: "cursorBin", + dsh: "dshBin", }; const DISPATCH_API_URL_ENV = "DISPATCH_API_URL"; @@ -537,7 +541,10 @@ export function buildAgentCommand( // interactive login shell in the chosen cwd/worktree. `-l` alone starts a // non-interactive login shell that exits immediately under `bash -c`, // which tears down the tmux session before the browser can attach. - if (type === "terminal") { + // dsh agents also get a plain shell in the pane: the ACP driver + // (agents/dsh) owns the harness process, and the pane is the human's + // console into the worktree. + if (type === "terminal" || type === "dsh") { return `${envPrefix} "\${SHELL:-/bin/bash}" -il`; } diff --git a/apps/server/src/agents/token-harvester.ts b/apps/server/src/agents/token-harvester.ts index 2e648e369..759c0b9f8 100644 --- a/apps/server/src/agents/token-harvester.ts +++ b/apps/server/src/agents/token-harvester.ts @@ -315,6 +315,8 @@ export async function harvestTokenUsage( agent: HarvestAgent, logger?: HarvestLogger ): Promise { + // dsh usage arrives on the ACP stream (agents/dsh/usage-recorder.ts). + if (agent.type === "dsh") return; if (agent.type === "codex") { await harvestCodexTokenUsage(pool, agent, logger); } else if (agent.type === "claude") { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5a81ea830..57983dd8b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -24,6 +24,10 @@ export type AppConfig = { claudeBin: string; opencodeBin: string; cursorBin: string; + /** Path to the `dsh` launcher (DeepSeek Harness). */ + dshBin: string; + /** DSH_HOME for agents Dispatch launches; never the user's own ~/.dsh. */ + dshHome: string; agentRuntime: "tmux" | "inert"; sessionPrefix: string; tls: TlsConfig | null; @@ -94,6 +98,11 @@ export function loadConfig(): AppConfig { "opencode", cursorBin: process.env.DISPATCH_CURSOR_BIN ?? process.env.CURSOR_BIN ?? "agent", + dshBin: process.env.DISPATCH_DSH_BIN ?? process.env.DSH_BIN ?? "dsh", + dshHome: resolveConfiguredPath( + process.env.DISPATCH_DSH_HOME ?? + path.join(os.homedir(), ".dispatch", "dsh") + ), agentRuntime: resolveAgentRuntime(), sessionPrefix: process.env.DISPATCH_SESSION_PREFIX ?? "dispatch", tls: loadTls(), diff --git a/apps/server/src/shared/agent-models.ts b/apps/server/src/shared/agent-models.ts index f2ee625f7..5f23c0ee0 100644 --- a/apps/server/src/shared/agent-models.ts +++ b/apps/server/src/shared/agent-models.ts @@ -41,6 +41,15 @@ export const AGENT_MODEL_OPTIONS: Partial< { id: "haiku", label: "Haiku" }, { id: "fable", label: "Fable" }, ], + // dsh ids are `provider/model`: the provider is a dsh LLM route name and the + // model is that route's id. Verified against `dsh --profile acp` session + // configOptions on 2026-09-04 (see docs/agent-model-catalog.md). + dsh: [ + { id: "deepseek-official/deepseek-v4-flash", label: "DeepSeek V4 Flash" }, + { id: "deepseek-official/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, + { id: "openai/gpt-5.2", label: "GPT-5.2 (OpenAI API key)" }, + { id: "openai/gpt-5.3-codex", label: "GPT-5.3 Codex (OpenAI API key)" }, + ], }; export function getAgentModelOptions( diff --git a/apps/server/test/agent-models.test.ts b/apps/server/test/agent-models.test.ts index 226207c32..f1484c4e6 100644 --- a/apps/server/test/agent-models.test.ts +++ b/apps/server/test/agent-models.test.ts @@ -176,3 +176,12 @@ describe("resolveAgentModelForUpdate", () => { ).toBe("opus"); }); }); + +describe("dsh catalog", () => { + it("lists provider-qualified ids for dsh", () => { + const ids = (AGENT_MODEL_OPTIONS.dsh ?? []).map((o) => o.id); + expect(ids).toContain("deepseek-official/deepseek-v4-flash"); + expect(ids).toContain("openai/gpt-5.2"); + for (const id of ids) expect(id).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/); + }); +}); diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index ae19f84dd..bf4377c8c 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -74,6 +74,8 @@ const testConfig = { claudeBin: "echo", opencodeBin: "echo", cursorBin: "echo", + dshBin: "echo", + dshHome: "/tmp/dispatch-test-dsh-home", agentRuntime: "tmux", sessionPrefix: "dispatch", tls: null, diff --git a/apps/server/test/mcp-url.test.ts b/apps/server/test/mcp-url.test.ts index f36d5eb66..c1ee2e79d 100644 --- a/apps/server/test/mcp-url.test.ts +++ b/apps/server/test/mcp-url.test.ts @@ -15,6 +15,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { claudeBin: "", opencodeBin: "", cursorBin: "", + dshBin: "", + dshHome: "", agentRuntime: "tmux", sessionPrefix: "dispatch", tls: null, diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index d7112eb40..690760ff0 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -21,6 +21,8 @@ const baseConfig: AppConfig = { claudeBin: "/opt/claude", opencodeBin: "/opt/opencode", cursorBin: "/opt/cursor", + dshBin: "/opt/dsh", + dshHome: "/tmp/dispatch-test-dsh-home", agentRuntime: "inert", sessionPrefix: "dispatch", tls: null, @@ -1253,3 +1255,20 @@ describe("buildLaunchGuidance — chat surface rule", () => { expect(without).not.toContain("dispatch_chat_post"); }); }); + +describe("dsh agents", () => { + it("launch into a login shell like terminal agents; the ACP driver owns the CLI", () => { + const cmd = buildAgentCommand( + baseConfig, + "dsh", + "standard", + [], + "/tmp/media", + SESSION, + false + ); + expect(cmd).toContain('"${SHELL:-/bin/bash}" -il'); + expect(cmd).not.toContain("--mcp-config"); + expect(cmd).not.toContain("--append-system-prompt"); + }); +}); diff --git a/apps/web/src/components/app/agent-type-icon.tsx b/apps/web/src/components/app/agent-type-icon.tsx index 140ec1085..db2356e83 100644 --- a/apps/web/src/components/app/agent-type-icon.tsx +++ b/apps/web/src/components/app/agent-type-icon.tsx @@ -25,7 +25,7 @@ const CODEX_LOGO_PATH = function normalizeAgentType( type?: string | null -): "codex" | "claude" | "opencode" | "cursor" | "terminal" | "unknown" { +): "codex" | "claude" | "opencode" | "cursor" | "dsh" | "terminal" | "unknown" { if (type === "claude") { return "claude"; } @@ -38,6 +38,9 @@ function normalizeAgentType( if (type === "terminal") { return "terminal"; } + if (type === "dsh") { + return "dsh"; + } if (type === "codex") { return "codex"; } @@ -62,9 +65,11 @@ export function AgentTypeIcon({ ? "Cursor" : normalizedType === "terminal" ? "Terminal" - : normalizedType === "codex" - ? "Codex" - : "Agent"; + : normalizedType === "dsh" + ? "DSH" + : normalizedType === "codex" + ? "Codex" + : "Agent"; const statusClass = eventType ? eventColorClass[eventType] : ""; const baseClass = statusClass ? "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-300" @@ -87,6 +92,23 @@ export function AgentTypeIcon({ ); } + if (normalizedType === "dsh") { + return ( + + DS + + ); + } + if (normalizedType === "terminal") { return ( = { codex: "Codex CLI by OpenAI.", cursor: "Cursor Agent CLI by Anysphere.", opencode: "OpenCode CLI — open-source terminal agent.", + dsh: "DeepSeek Harness (dsh) — open-source, model-agnostic, streams into the Chat tab.", terminal: "Raw shell session with no AI agent.", }; diff --git a/apps/web/src/lib/agent-types.ts b/apps/web/src/lib/agent-types.ts index 4a5568815..5c16818c2 100644 --- a/apps/web/src/lib/agent-types.ts +++ b/apps/web/src/lib/agent-types.ts @@ -21,6 +21,7 @@ export const AGENT_TYPE_LABELS: Record = { codex: "Codex", cursor: "Cursor", opencode: "OpenCode", + dsh: "DSH", terminal: "Terminal", }; diff --git a/docs/agent-model-catalog.md b/docs/agent-model-catalog.md index afa2e5852..c33bd9807 100644 --- a/docs/agent-model-catalog.md +++ b/docs/agent-model-catalog.md @@ -89,3 +89,12 @@ parses this section for `**YYYY-MM-DD**` and fails once a date is behind us. - None currently tracked. `gpt-5.4` and `gpt-5.4-mini` retired 2026-08-31 and were removed; their successors are `gpt-5.6-terra` and `gpt-5.6-luna`. + +## dsh (DeepSeek Harness) + +Ids are `provider/model`. Evidence bar: the id must appear in the `model` +config option returned by `session/new` on `dsh --profile acp` for the +installed version. Procedure: run `dsh --profile acp --dump-config`, then open +a session over ACP and copy the `value` pairs from the `model` config option +verbatim. Routes other than `deepseek-official` need their provider declared +in the per-agent overlay's `llm-pi-ai` row; `openai` is declared by default. diff --git a/packages/shared/src/agent-types.ts b/packages/shared/src/agent-types.ts index ff09fec89..39c83d4b6 100644 --- a/packages/shared/src/agent-types.ts +++ b/packages/shared/src/agent-types.ts @@ -13,6 +13,7 @@ export const AGENT_TYPES = [ "codex", "cursor", "opencode", + "dsh", "terminal", ] as const; export type AgentType = (typeof AGENT_TYPES)[number]; @@ -24,5 +25,6 @@ export const CLI_AGENT_TYPES = [ "codex", "cursor", "opencode", + "dsh", ] as const; export type CliAgentType = (typeof CLI_AGENT_TYPES)[number]; From 3ad34a2fdb10ec6804605f33176968e53870426a Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:08:13 -0700 Subject: [PATCH 004/254] feat(dsh): agent_stream_events table and StreamStore Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/dsh/stream-store.ts | 133 ++++++++++++++++++ .../migrations/0048_agent-stream-events.sql | 22 +++ apps/server/test/dsh-stream-store.test.ts | 72 ++++++++++ 3 files changed, 227 insertions(+) create mode 100644 apps/server/src/agents/dsh/stream-store.ts create mode 100644 apps/server/src/db/migrations/0048_agent-stream-events.sql create mode 100644 apps/server/test/dsh-stream-store.test.ts diff --git a/apps/server/src/agents/dsh/stream-store.ts b/apps/server/src/agents/dsh/stream-store.ts new file mode 100644 index 000000000..61bfec974 --- /dev/null +++ b/apps/server/src/agents/dsh/stream-store.ts @@ -0,0 +1,133 @@ +import type { Queryable } from "../../chat/store.js"; + +export type StreamEventKind = "assistant" | "thought" | "tool_call" | "status"; + +export type StreamEventRow = { + id: number; + agentId: string; + seq: number; + kind: StreamEventKind; + /** toolCallId for tool_call rows; null for everything else. */ + key: string | null; + payload: Record; + createdAt: Date; + updatedAt: Date; +}; + +type Row = { + id: string | number; + agent_id: string; + seq: number; + kind: StreamEventKind; + key: string | null; + payload: Record; + created_at: Date; + updated_at: Date; +}; + +function toRow(r: Row): StreamEventRow { + return { + id: Number(r.id), + agentId: r.agent_id, + seq: r.seq, + kind: r.kind, + key: r.key, + payload: r.payload, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +const INSERT_SQL = ` + INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + SELECT $1, COALESCE(MAX(seq), 0) + 1, $2, $3, $4::jsonb + FROM agent_stream_events + WHERE agent_id = $1 + RETURNING *`; + +/** + * Rows in `agent_stream_events`: the durable projection of a stream-driven + * harness (dsh over ACP) that the Chat feed reads. Append-only except for + * tool calls, which are rewritten in place under their toolCallId. + */ +export class StreamStore { + constructor(private readonly db: Queryable) {} + + async append( + agentId: string, + kind: StreamEventKind, + payload: Record, + key: string | null = null + ): Promise { + const result = await this.db.query(INSERT_SQL, [ + agentId, + kind, + key, + JSON.stringify(payload), + ]); + return toRow(result.rows[0]); + } + + async upsertByKey( + agentId: string, + kind: StreamEventKind, + key: string, + payload: Record + ): Promise { + const existing = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 AND kind = $2 AND key = $3`, + [agentId, kind, key] + ); + const found = existing.rows[0]; + if (found) { + const updated = await this.db.query( + `UPDATE agent_stream_events + SET payload = $2::jsonb, updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [found.id, JSON.stringify(payload)] + ); + return toRow(updated.rows[0]); + } + return this.append(agentId, kind, payload, key); + } + + async latest( + agentId: string, + kind: StreamEventKind + ): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 AND kind = $2 + ORDER BY seq DESC + LIMIT 1`, + [agentId, kind] + ); + return result.rows[0] ? toRow(result.rows[0]) : null; + } + + async updatePayload( + id: number, + payload: Record + ): Promise { + await this.db.query( + `UPDATE agent_stream_events + SET payload = $2::jsonb, updated_at = NOW() + WHERE id = $1`, + [id, JSON.stringify(payload)] + ); + } + + /** Newest first. */ + async list(agentId: string, limit: number): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 + ORDER BY seq DESC + LIMIT $2`, + [agentId, limit] + ); + return result.rows.map(toRow); + } +} diff --git a/apps/server/src/db/migrations/0048_agent-stream-events.sql b/apps/server/src/db/migrations/0048_agent-stream-events.sql new file mode 100644 index 000000000..04cd0689f --- /dev/null +++ b/apps/server/src/db/migrations/0048_agent-stream-events.sql @@ -0,0 +1,22 @@ +-- Stream events from harnesses Dispatch drives over a protocol (dsh over +-- ACP). One row per assistant message, thought, or tool call; tool calls are +-- rewritten in place as their status changes (key = toolCallId). seq orders +-- rows within one agent and never changes after insert. +CREATE TABLE IF NOT EXISTS agent_stream_events ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('assistant', 'thought', 'tool_call', 'status')), + key TEXT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (agent_id, seq) +); + +CREATE UNIQUE INDEX IF NOT EXISTS agent_stream_events_agent_key + ON agent_stream_events (agent_id, kind, key) + WHERE key IS NOT NULL; + +CREATE INDEX IF NOT EXISTS agent_stream_events_agent_created + ON agent_stream_events (agent_id, created_at DESC, id DESC); diff --git a/apps/server/test/dsh-stream-store.test.ts b/apps/server/test/dsh-stream-store.test.ts new file mode 100644 index 000000000..e288895b2 --- /dev/null +++ b/apps/server/test/dsh-stream-store.test.ts @@ -0,0 +1,72 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { StreamStore } from "../src/agents/dsh/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_stream_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'Stream A', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +describe("StreamStore", () => { + it("appends rows with a per-agent increasing seq", async () => { + const a = await store.append(A, "assistant", { text: "hi" }); + const b = await store.append(A, "status", { message: "x" }); + expect(b.seq).toBe(a.seq + 1); + expect(a.key).toBeNull(); + }); + + it("upserts a tool call by key without changing its seq", async () => { + const first = await store.upsertByKey(A, "tool_call", "call_1", { + status: "pending", + }); + const second = await store.upsertByKey(A, "tool_call", "call_1", { + status: "completed", + }); + expect(second.id).toBe(first.id); + expect(second.seq).toBe(first.seq); + expect(second.payload).toEqual({ status: "completed" }); + }); + + it("returns the latest row of a kind and updates a payload in place", async () => { + const row = await store.append(A, "assistant", { text: "a" }); + await store.updatePayload(row.id, { text: "ab" }); + const latest = await store.latest(A, "assistant"); + expect(latest?.id).toBe(row.id); + expect(latest?.payload).toEqual({ text: "ab" }); + }); + + it("lists newest first, bounded by limit", async () => { + for (let i = 0; i < 5; i++) await store.append(A, "status", { i }); + const rows = await store.list(A, 3); + expect(rows.map((r) => r.payload.i)).toEqual([4, 3, 2]); + }); + + it("cascades with the agent", async () => { + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ('agt_stream_gone', 'Gone', '/tmp', 'running')` + ); + await store.append("agt_stream_gone", "status", { message: "bye" }); + await pool.query(`DELETE FROM agents WHERE id = 'agt_stream_gone'`); + const rows = await store.list("agt_stream_gone", 10); + expect(rows).toEqual([]); + }); +}); From af43f6c54ed5ab9de0927538dd2ea6b1fa7d97f7 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:09:26 -0700 Subject: [PATCH 005/254] feat(dsh): assistant and activity chat feed entries from stream rows Co-Authored-By: Claude Fable 5.1 --- apps/server/src/chat/feed.ts | 82 ++++++++++++++++++- apps/server/test/chat-feed.test.ts | 43 ++++++++++ .../web/src/components/app/chat/chat-feed.tsx | 3 + packages/shared/src/chat-types.ts | 37 ++++++++- packages/shared/src/index.ts | 3 + 5 files changed, 166 insertions(+), 2 deletions(-) diff --git a/apps/server/src/chat/feed.ts b/apps/server/src/chat/feed.ts index 219a5eba4..61779838f 100644 --- a/apps/server/src/chat/feed.ts +++ b/apps/server/src/chat/feed.ts @@ -1,5 +1,8 @@ import type { + ChatActivityEntry, + ChatActivityStatus, ChatAgentMessageEntry, + ChatAssistantEntry, ChatFeedEntry, ChatFeedResponse, ChatMediaEntry, @@ -38,6 +41,10 @@ export type FeedCursor = { }; const SOURCE_RANK: Record = { + // assistant and activity share one source (agent_stream_events), so they + // share one rank: the cursor tie-break on id is valid across both. + assistant: 5, + activity: 5, review: 4, chat: 3, status: 2, @@ -63,6 +70,8 @@ function isValidCursorId(type: ChatFeedEntry["type"], id: string): boolean { case "status": case "media": case "review": + case "assistant": + case "activity": return SERIAL_ID_RE.test(id) && Number(id) <= 2_147_483_647; } } @@ -391,6 +400,75 @@ async function listReviewEntries( })); } +type StreamPayload = { + text?: string; + streaming?: boolean; + toolKind?: string; + title?: string; + status?: ChatActivityStatus; + locations?: { path: string; line?: number }[]; + diff?: { path: string; oldText: string | null; newText: string } | null; + terminalOutput?: string | null; +}; + +/** + * Stream rows from a protocol-driven harness (dsh over ACP): assistant text + * and tool calls. Thoughts and status rows stay out of the feed. + */ +async function listStreamEntries( + db: Queryable, + agentId: string, + cursor: FeedCursor | null, + limit: number +): Promise[]> { + const params: unknown[] = [agentId]; + const clause = cursorClause("assistant", "int", cursor, params); + params.push(limit); + const result = await db.query<{ + id: number; + kind: "assistant" | "tool_call"; + payload: StreamPayload; + created_at: Date; + at_key: string; + }>( + `SELECT id, kind, payload, created_at, ${AT_KEY_SQL} AS at_key + FROM agent_stream_events + WHERE agent_id = $1 AND kind IN ('assistant', 'tool_call') ${clause} + ORDER BY created_at DESC, id DESC + LIMIT $${params.length}`, + params + ); + return result.rows.map((row) => { + const at = row.created_at.toISOString(); + const entry: ChatAssistantEntry | ChatActivityEntry = + row.kind === "assistant" + ? { + type: "assistant", + id: `stream:${row.id}`, + text: row.payload.text ?? "", + streaming: row.payload.streaming === true, + at, + } + : { + type: "activity", + id: `stream:${row.id}`, + toolKind: row.payload.toolKind ?? "other", + title: row.payload.title ?? "", + status: row.payload.status ?? "pending", + locations: row.payload.locations ?? [], + diff: row.payload.diff ?? null, + terminalOutput: row.payload.terminalOutput ?? null, + at, + }; + return { + entry, + atKey: row.at_key, + rawId: String(row.id), + idKey: intKey(Number(row.id)), + }; + }); +} + /** Newest first: (atKey, source rank, id) descending. */ function compareNewestFirst(a: Keyed, b: Keyed) { if (a.atKey !== b.atKey) return a.atKey < b.atKey ? 1 : -1; @@ -416,13 +494,14 @@ export async function composeChatFeed( const limit = clampFeedLimit(opts.limit); const cursor = opts.cursor ?? null; const { db } = store; - const [chat, status, agentMessages, media, reviews, unreadCount] = + const [chat, status, agentMessages, media, reviews, stream, unreadCount] = await Promise.all([ listChatEntries(db, agentId, cursor, limit + 1), listStatusEntries(db, agentId, cursor, limit + 1), listAgentMessageEntries(db, agentId, cursor, limit + 1), listMediaEntries(db, agentId, cursor, limit + 1), listReviewEntries(db, agentId, cursor, limit + 1), + listStreamEntries(db, agentId, cursor, limit + 1), store.countUnread(agentId), ]); @@ -432,6 +511,7 @@ export async function composeChatFeed( ...agentMessages, ...media, ...reviews, + ...stream, ].sort(compareNewestFirst); const hasMore = merged.length > limit; const page = merged.slice(0, limit); diff --git a/apps/server/test/chat-feed.test.ts b/apps/server/test/chat-feed.test.ts index 50e469693..e39b8c4ab 100644 --- a/apps/server/test/chat-feed.test.ts +++ b/apps/server/test/chat-feed.test.ts @@ -40,6 +40,7 @@ beforeEach(async () => { await pool.query("DELETE FROM agent_messages"); await pool.query("DELETE FROM media"); await pool.query("DELETE FROM reviews"); + await pool.query("DELETE FROM agent_stream_events"); }); const at = (s: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, s)); @@ -415,3 +416,45 @@ describe("composeChatFeed", () => { expect(clampFeedLimit(42.7)).toBe(42); }); }); + +describe("stream sources", () => { + it("surfaces assistant and tool_call rows as assistant and activity entries", async () => { + await pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) VALUES + ($1, 1, 'assistant', NULL, '{"text":"Reading files","streaming":false}'), + ($1, 2, 'tool_call', 'call_1', '{"toolKind":"read","title":"Read README.md","status":"completed","locations":[{"path":"/w/README.md"}],"diff":null,"terminalOutput":null}'), + ($1, 3, 'thought', NULL, '{"text":"hmm"}'), + ($1, 4, 'status', NULL, '{"message":"turn failed"}')`, + [A] + ); + const feed = await composeChatFeed(store, A); + expect(feed.entries.map((e) => e.type)).toEqual(["assistant", "activity"]); + const activity = feed.entries[1]; + if (activity.type !== "activity") throw new Error("expected activity"); + expect(activity.title).toBe("Read README.md"); + expect(activity.status).toBe("completed"); + expect(activity.locations).toEqual([{ path: "/w/README.md" }]); + expect(activity.id).toMatch(/^stream:\d+$/); + }); + + it("pages across stream entries with the cursor", async () => { + for (let i = 1; i <= 4; i++) { + await pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, payload, created_at) + VALUES ($1, $2, 'assistant', $3::jsonb, $4)`, + [A, i, JSON.stringify({ text: `m${i}`, streaming: false }), at(i)] + ); + } + const page1 = await composeChatFeed(store, A, { limit: 2 }); + expect(page1.hasMore).toBe(true); + const page2 = await composeChatFeed(store, A, { + limit: 2, + cursor: decodeFeedCursor(page1.nextCursor!), + }); + expect(page2.hasMore).toBe(false); + const texts = [...page2.entries, ...page1.entries].map((e) => + e.type === "assistant" ? e.text : "" + ); + expect(texts).toEqual(["m1", "m2", "m3", "m4"]); + }); +}); diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 52e02494f..6ba73b3f0 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -181,6 +181,9 @@ function authorKey( return "agent"; case "review": return reviewAuthor(entry, ctx).key; + case "assistant": + case "activity": + return "agent"; } } diff --git a/packages/shared/src/chat-types.ts b/packages/shared/src/chat-types.ts index 30d0326f9..62d78e42d 100644 --- a/packages/shared/src/chat-types.ts +++ b/packages/shared/src/chat-types.ts @@ -172,6 +172,39 @@ export type ChatReviewEntry = { at: string; }; +/** One assistant message from a stream-driven harness (dsh over ACP). */ +export type ChatAssistantEntry = { + type: "assistant"; + id: string; + text: string; + /** True while chunks are still arriving for this message. */ + streaming: boolean; + at: string; +}; + +export type ChatActivityStatus = + | "pending" + | "in_progress" + | "completed" + | "failed"; + +/** + * One tool call from a stream-driven harness, rewritten in place as it + * settles. `toolKind` follows the Agent Client Protocol vocabulary (read, + * edit, delete, move, search, execute, think, fetch, other). + */ +export type ChatActivityEntry = { + type: "activity"; + id: string; + toolKind: string; + title: string; + status: ChatActivityStatus; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; + at: string; +}; + export type ChatMessageEntry = { type: "chat"; id: string; @@ -184,7 +217,9 @@ export type ChatFeedEntry = | ChatStatusEntry | ChatAgentMessageEntry | ChatMediaEntry - | ChatReviewEntry; + | ChatReviewEntry + | ChatAssistantEntry + | ChatActivityEntry; export type ChatFeedResponse = { entries: ChatFeedEntry[]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bf30d4f1b..98f52ca03 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,7 +29,10 @@ export { CHAT_QUESTION_OPTIONS_MAX, } from "./chat-types.js"; export type { + ChatActivityEntry, + ChatActivityStatus, ChatAgentMessageEntry, + ChatAssistantEntry, ChatAnswer, ChatAnswerRequest, ChatAnswerResponse, From 8b9b5ed1da1d0d497bd200dabc744e2acf9d030e Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:09:54 -0700 Subject: [PATCH 006/254] feat(dsh): per-agent profile overlay builder Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/dsh/overlay.ts | 69 +++++++++++++++++++ apps/server/test/dsh-overlay.test.ts | 95 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 apps/server/src/agents/dsh/overlay.ts create mode 100644 apps/server/test/dsh-overlay.test.ts diff --git a/apps/server/src/agents/dsh/overlay.ts b/apps/server/src/agents/dsh/overlay.ts new file mode 100644 index 000000000..d608458ce --- /dev/null +++ b/apps/server/src/agents/dsh/overlay.ts @@ -0,0 +1,69 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { stringify } from "yaml"; + +/** One `llm-pi-ai` provider route (see dsh's `@deepseek-ai/dsh-llm-pi-ai`). */ +export type ProviderRoute = { + apiKeyEnv?: string; + baseURL?: string; + api?: string; + displayName?: string; + models?: { id: string; contextWindow?: number }[]; +}; + +export type OverlayInput = { + /** `provider/model`, or null to keep the profile default. */ + model: string | null; + /** Full persona text: launch guidance plus persona brief or personality. */ + persona: string; + /** Extra provider routes; the OpenAI route is declared by default. */ + providers?: Record; +}; + +const DEFAULT_PROVIDERS: Record = { + openai: { apiKeyEnv: "OPENAI_API_KEY" }, +}; + +export function splitModelId(model: string): { + provider: string; + model: string; +} { + const idx = model.indexOf("/"); + if (idx <= 0 || idx === model.length - 1) { + throw new Error(`dsh model ids are provider/model; got "${model}"`); + } + return { provider: model.slice(0, idx), model: model.slice(idx + 1) }; +} + +/** + * The per-agent `--patch` layer. Each entry replaces the config of the row + * with that id in the composed acp profile: provider routes, the deployment + * persona, and the default model for both new agents and ACP sessions. + */ +export function buildOverlayYaml(input: OverlayInput): string { + const rows: { id: string; config: Record }[] = [ + { + id: "llm-pi-ai", + config: { providers: input.providers ?? DEFAULT_PROVIDERS }, + }, + { id: "system-prompt", config: { persona: input.persona } }, + ]; + if (input.model) { + const selected = splitModelId(input.model); + rows.push({ id: "agent-default-model", config: selected }); + rows.push({ id: "acp", config: selected }); + } + return stringify(rows); +} + +/** Writes `/.patch.yml` and returns its path. */ +export async function writeOverlay( + dir: string, + agentId: string, + input: OverlayInput +): Promise { + await mkdir(dir, { recursive: true }); + const file = path.join(dir, `${agentId}.patch.yml`); + await writeFile(file, buildOverlayYaml(input), "utf8"); + return file; +} diff --git a/apps/server/test/dsh-overlay.test.ts b/apps/server/test/dsh-overlay.test.ts new file mode 100644 index 000000000..eb4c7ab55 --- /dev/null +++ b/apps/server/test/dsh-overlay.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parse } from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildOverlayYaml, + splitModelId, + writeOverlay, +} from "../src/agents/dsh/overlay.js"; + +type Row = { id: string; config: Record }; + +describe("splitModelId", () => { + it("splits provider/model", () => { + expect(splitModelId("openai/gpt-5.2")).toEqual({ + provider: "openai", + model: "gpt-5.2", + }); + }); + + it("rejects ids without a provider", () => { + expect(() => splitModelId("gpt-5.2")).toThrow("provider/model"); + expect(() => splitModelId("openai/")).toThrow("provider/model"); + }); +}); + +describe("buildOverlayYaml", () => { + it("emits llm routes, persona, and default model rows", () => { + const rows = parse( + buildOverlayYaml({ + model: "openai/gpt-5.2", + persona: "You are {{model}} in {{cwd}}.", + }) + ) as Row[]; + const byId = Object.fromEntries(rows.map((r) => [r.id, r.config])); + expect(byId["llm-pi-ai"]).toEqual({ + providers: { openai: { apiKeyEnv: "OPENAI_API_KEY" } }, + }); + expect(byId["system-prompt"]).toEqual({ + persona: "You are {{model}} in {{cwd}}.", + }); + expect(byId["agent-default-model"]).toEqual({ + provider: "openai", + model: "gpt-5.2", + }); + expect(byId["acp"]).toEqual({ provider: "openai", model: "gpt-5.2" }); + }); + + it("omits model rows when no model is chosen", () => { + const rows = parse( + buildOverlayYaml({ model: null, persona: "p" }) + ) as Row[]; + expect(rows.map((r) => r.id)).toEqual(["llm-pi-ai", "system-prompt"]); + }); + + it("lets the caller replace the provider routes", () => { + const rows = parse( + buildOverlayYaml({ + model: "local/qwen3-coder", + persona: "p", + providers: { + local: { + api: "openai-completions", + baseURL: "http://127.0.0.1:11434/v1", + models: [{ id: "qwen3-coder", contextWindow: 131072 }], + }, + }, + }) + ) as Row[]; + const llm = rows.find((r) => r.id === "llm-pi-ai")?.config as { + providers: Record; + }; + expect(Object.keys(llm.providers)).toEqual(["local"]); + }); +}); + +describe("writeOverlay", () => { + let dir = ""; + afterEach(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it("writes /.patch.yml, creating the directory", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "dsh-overlay-")); + const nested = path.join(dir, "overlays"); + const file = await writeOverlay(nested, "agt_1", { + model: null, + persona: "p", + }); + expect(file).toBe(path.join(nested, "agt_1.patch.yml")); + expect(await readFile(file, "utf8")).toContain("system-prompt"); + }); +}); From 92fbdc3904e5e7763754cb389941456c01a12f1b Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:12:21 -0700 Subject: [PATCH 007/254] feat(dsh): ACP driver with one child process per agent Spawns dsh --profile acp with the per-agent overlay, attaches Dispatch's HTTP MCP server at session creation (or resume), forwards session updates and turn boundaries as typed events, and tears the child down through close, stdin EOF, SIGTERM, SIGKILL. Tested against an in-process fake ACP agent; no test spawns the real binary. Co-Authored-By: Claude Fable 5.1 --- apps/server/package.json | 1 + apps/server/src/agents/dsh/driver.ts | 320 +++++++++++++++++++++ apps/server/test/dsh-driver.test.ts | 176 ++++++++++++ apps/server/test/helpers/fake-acp-agent.ts | 100 +++++++ pnpm-lock.yaml | 15 + 5 files changed, 612 insertions(+) create mode 100644 apps/server/src/agents/dsh/driver.ts create mode 100644 apps/server/test/dsh-driver.test.ts create mode 100644 apps/server/test/helpers/fake-acp-agent.ts diff --git a/apps/server/package.json b/apps/server/package.json index e3af66c4f..dec2d32e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "db:migrate": "bun run prepare:runtime-assets && bun src/db/migrate.ts" }, "dependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@dispatch/shared": "workspace:*", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.4.0", diff --git a/apps/server/src/agents/dsh/driver.ts b/apps/server/src/agents/dsh/driver.ts new file mode 100644 index 000000000..c4ae22ca0 --- /dev/null +++ b/apps/server/src/agents/dsh/driver.ts @@ -0,0 +1,320 @@ +import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; + +/** + * The ACP client for DeepSeek Harness. One `dsh --profile acp` child per + * Dispatch agent; this module is the only place in the server that speaks + * the protocol. Everything downstream consumes {@link DriverEvent}s. + */ + +export type DriverUpdate = acp.SessionUpdate; +export type DriverUsage = acp.Usage; + +export type DriverLaunch = { + agentId: string; + cwd: string; + /** The per-agent `--patch` overlay (see overlay.ts). */ + overlayPath: string; + /** Dispatch's streamable HTTP MCP endpoint for this agent. */ + mcp: { url: string; token: string }; + /** Resume this ACP session when set; otherwise create one. */ + sessionId: string | null; + env: NodeJS.ProcessEnv; +}; + +export type DriverEvent = + | { type: "update"; agentId: string; update: DriverUpdate } + | { type: "turn"; agentId: string; state: "started" } + | { + type: "turn"; + agentId: string; + state: "settled"; + stopReason?: acp.StopReason; + /** Cumulative session usage reported with the prompt response. */ + usage?: DriverUsage; + error?: string; + } + | { + type: "exit"; + agentId: string; + code: number | null; + signal: string | null; + stderrTail: string; + }; + +export type DriverListener = (event: DriverEvent) => void; + +export type DriverLogger = { + info: (obj: Record, msg: string) => void; + warn: (obj: Record, msg: string) => void; + error: (obj: Record, msg: string) => void; + debug: (obj: Record, msg: string) => void; +}; + +export type ChildProcessLike = Pick< + ChildProcess, + "stdin" | "stdout" | "stderr" | "on" | "kill" | "killed" +>; + +export type SpawnFn = ( + bin: string, + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv } +) => ChildProcessLike; + +type Live = { + child: ChildProcessLike; + conn: acp.ClientSideConnection; + sessionId: string; + stderrTail: string[]; + exited: Promise<{ code: number | null; signal: string | null }>; +}; + +const STDERR_TAIL_LINES = 20; +const TEARDOWN_STEP_MS = 1_500; + +/** + * The ACP SDK reports an agent-side exception as JSON-RPC "Internal error" + * and keeps the real message in `data.details` (dsh itself does the same + * for a failed turn), so surface that detail instead of the bare code. + */ +export function describeRpcError(err: unknown): string { + if (!(err instanceof Error)) return String(err); + const data = (err as { data?: unknown }).data; + let detail: string | null = null; + if (typeof data === "string") detail = data; + else if (data && typeof data === "object") { + const details = (data as { details?: unknown }).details; + if (typeof details === "string") detail = details; + else if (Object.keys(data).length > 0) detail = JSON.stringify(data); + } + return detail && !err.message.includes(detail) + ? `${err.message}: ${detail}` + : err.message; +} + +function defaultSpawn( + bin: string, + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv } +): ChildProcessLike { + return nodeSpawn(bin, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] }); +} + +export class DshDriver { + private readonly live = new Map(); + private readonly listeners = new Set(); + private readonly spawnFn: SpawnFn; + + constructor( + private readonly opts: { + dshBin: string; + dshHome: string; + spawn?: SpawnFn; + logger: DriverLogger; + } + ) { + this.spawnFn = opts.spawn ?? defaultSpawn; + } + + onEvent(listener: DriverListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + isRunning(agentId: string): boolean { + return this.live.has(agentId); + } + + async start(launch: DriverLaunch): Promise<{ sessionId: string }> { + if (this.live.has(launch.agentId)) { + throw new Error(`dsh already running for ${launch.agentId}`); + } + const child = this.spawnFn( + this.opts.dshBin, + ["--profile", "acp", "--patch", launch.overlayPath], + { + cwd: launch.cwd, + env: { + ...launch.env, + DSH_HOME: this.opts.dshHome, + DSH_PERMISSION_MODE: "danger-full-access", + }, + } + ); + const stderrTail: string[] = []; + child.stderr?.on("data", (chunk: Buffer) => { + for (const line of chunk.toString("utf8").split("\n")) { + if (!line.trim()) continue; + stderrTail.push(line); + if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift(); + } + }); + const exited = new Promise<{ code: number | null; signal: string | null }>( + (resolve) => { + child.on("exit", (code, signal) => + resolve({ code, signal: signal ?? null }) + ); + } + ); + + const client: acp.Client = { + sessionUpdate: async (params) => { + this.emit({ + type: "update", + agentId: launch.agentId, + update: params.update, + }); + }, + // Permission prompts never fire under danger-full-access; if one does, + // allow it once rather than wedge the turn. + requestPermission: async (params) => { + const allow = + params.options.find((o) => o.kind === "allow_once") ?? + params.options[0]; + return { + outcome: { outcome: "selected", optionId: allow.optionId }, + }; + }, + }; + if (!child.stdin || !child.stdout) { + child.kill("SIGKILL"); + throw new Error("dsh start failed: child has no stdio pipes"); + } + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout) + ); + const conn = new acp.ClientSideConnection(() => client, stream); + + try { + await conn.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + const mcpServers: acp.McpServer[] = [ + { + type: "http", + name: "dispatch", + url: launch.mcp.url, + headers: [ + { name: "Authorization", value: `Bearer ${launch.mcp.token}` }, + ], + }, + ]; + let sessionId: string; + if (launch.sessionId) { + await conn.resumeSession({ + sessionId: launch.sessionId, + cwd: launch.cwd, + mcpServers, + }); + sessionId = launch.sessionId; + } else { + const res = await conn.newSession({ cwd: launch.cwd, mcpServers }); + sessionId = res.sessionId; + } + const entry: Live = { child, conn, sessionId, stderrTail, exited }; + this.live.set(launch.agentId, entry); + void exited.then(({ code, signal }) => { + if (this.live.get(launch.agentId) === entry) { + this.live.delete(launch.agentId); + } + this.emit({ + type: "exit", + agentId: launch.agentId, + code, + signal, + stderrTail: stderrTail.join("\n"), + }); + }); + this.opts.logger.info( + { agentId: launch.agentId, sessionId, resumed: !!launch.sessionId }, + "dsh session ready" + ); + return { sessionId }; + } catch (err) { + child.kill("SIGKILL"); + const tail = stderrTail.length ? `\n${stderrTail.join("\n")}` : ""; + throw new Error(`dsh start failed: ${describeRpcError(err)}${tail}`, { + cause: err, + }); + } + } + + /** Runs one turn; resolves when the agent settles it. */ + async prompt(agentId: string, text: string): Promise { + const entry = this.require(agentId); + this.emit({ type: "turn", agentId, state: "started" }); + try { + const res = await entry.conn.prompt({ + sessionId: entry.sessionId, + prompt: [{ type: "text", text }], + }); + this.emit({ + type: "turn", + agentId, + state: "settled", + stopReason: res.stopReason, + ...(res.usage ? { usage: res.usage } : {}), + }); + } catch (err) { + const message = describeRpcError(err); + this.emit({ type: "turn", agentId, state: "settled", error: message }); + throw new Error(message, { cause: err }); + } + } + + async cancel(agentId: string): Promise { + const entry = this.require(agentId); + await entry.conn.cancel({ sessionId: entry.sessionId }); + } + + /** Close the session, then walk stdin EOF, SIGTERM, SIGKILL until exit. */ + async stop(agentId: string): Promise { + const entry = this.live.get(agentId); + if (!entry) return; + try { + await Promise.race([ + entry.conn.closeSession({ sessionId: entry.sessionId }), + new Promise((resolve) => setTimeout(resolve, TEARDOWN_STEP_MS)), + ]); + } catch (err) { + this.opts.logger.debug( + { err, agentId }, + "dsh session close failed; continuing teardown" + ); + } + const exitedWithin = (ms: number) => + Promise.race([ + entry.exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]); + entry.child.stdin?.end(); + if (!(await exitedWithin(TEARDOWN_STEP_MS))) entry.child.kill("SIGTERM"); + if (!(await exitedWithin(TEARDOWN_STEP_MS))) entry.child.kill("SIGKILL"); + await entry.exited; + this.live.delete(agentId); + } + + private emit(event: DriverEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (err) { + this.opts.logger.warn({ err }, "dsh driver listener threw"); + } + } + } + + private require(agentId: string): Live { + const entry = this.live.get(agentId); + if (!entry) throw new Error(`dsh is not running for ${agentId}`); + return entry; + } +} diff --git a/apps/server/test/dsh-driver.test.ts b/apps/server/test/dsh-driver.test.ts new file mode 100644 index 000000000..97b72fc98 --- /dev/null +++ b/apps/server/test/dsh-driver.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DshDriver, type DriverEvent } from "../src/agents/dsh/driver.js"; +import { createFakeAcpAgent } from "./helpers/fake-acp-agent.js"; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +function launch(agentId = "agt_1") { + return { + agentId, + cwd: "/tmp/w", + overlayPath: "/tmp/w/agt_1.patch.yml", + mcp: { url: "http://127.0.0.1:1/api/mcp/agt_1", token: "tok" }, + sessionId: null, + env: { PATH: "/usr/bin" }, + }; +} + +describe("DshDriver", () => { + it("spawns dsh with the acp profile, overlay, cwd, env, and attaches the MCP server", async () => { + const fake = createFakeAcpAgent(); + const spawn = vi.fn(() => fake.child); + const driver = new DshDriver({ + dshBin: "/bin/dsh", + dshHome: "/home/dsh", + spawn, + logger, + }); + const { sessionId } = await driver.start(launch()); + expect(sessionId).toBe("sess_1"); + expect(spawn).toHaveBeenCalledWith( + "/bin/dsh", + ["--profile", "acp", "--patch", "/tmp/w/agt_1.patch.yml"], + expect.objectContaining({ + cwd: "/tmp/w", + env: expect.objectContaining({ + DSH_HOME: "/home/dsh", + DSH_PERMISSION_MODE: "danger-full-access", + PATH: "/usr/bin", + }), + }) + ); + const req = fake.seen.newSession[0]; + expect(req.cwd).toBe("/tmp/w"); + expect(req.mcpServers).toEqual([ + { + type: "http", + name: "dispatch", + url: "http://127.0.0.1:1/api/mcp/agt_1", + headers: [{ name: "Authorization", value: "Bearer tok" }], + }, + ]); + expect(driver.isRunning("agt_1")).toBe(true); + await driver.stop("agt_1"); + }); + + it("forwards updates and turn boundaries while a prompt runs", async () => { + const fake = createFakeAcpAgent({ + turn: async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }); + return "end_turn"; + }, + }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.prompt("agt_1", "hello"); + expect(fake.seen.prompts).toEqual(["hello"]); + expect(events.map((e) => e.type)).toEqual(["turn", "update", "turn"]); + expect(events[2]).toMatchObject({ + type: "turn", + state: "settled", + stopReason: "end_turn", + }); + await driver.stop("agt_1"); + }); + + it("resumes when a session id is given", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const { sessionId } = await driver.start({ + ...launch(), + sessionId: "sess_prev", + }); + expect(sessionId).toBe("sess_prev"); + expect(fake.seen.newSession).toHaveLength(0); + expect(fake.seen.resumeSession[0]).toMatchObject({ + sessionId: "sess_prev", + cwd: "/tmp/w", + }); + await driver.stop("agt_1"); + }); + + it("stop closes the session and reaps the child", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.stop("agt_1"); + expect(fake.seen.closes).toBe(1); + expect(driver.isRunning("agt_1")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "exit", agentId: "agt_1" }); + }); + + it("refuses to start twice for one agent", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + await driver.start(launch()); + await expect(driver.start(launch())).rejects.toThrow(/already running/); + await driver.stop("agt_1"); + }); + + it("a prompt rejected by the agent settles the turn with an error", async () => { + const fake = createFakeAcpAgent({ + turn: async () => { + throw new Error("no API key"); + }, + }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await expect(driver.prompt("agt_1", "x")).rejects.toThrow(/no API key/); + expect(events.at(-1)).toMatchObject({ + type: "turn", + state: "settled", + error: expect.stringContaining("no API key"), + }); + await driver.stop("agt_1"); + }); + + it("prompting an agent that is not running throws", async () => { + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => createFakeAcpAgent().child, + logger, + }); + await expect(driver.prompt("agt_nope", "x")).rejects.toThrow(/not running/); + }); +}); diff --git a/apps/server/test/helpers/fake-acp-agent.ts b/apps/server/test/helpers/fake-acp-agent.ts new file mode 100644 index 000000000..3142d920e --- /dev/null +++ b/apps/server/test/helpers/fake-acp-agent.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; + +export type FakeTurn = ( + prompt: string, + emit: (update: acp.SessionUpdate) => Promise +) => Promise; + +/** + * An in-process ACP agent wired to a ChildProcess-like object. The driver's + * injected `spawn` returns `child`; the fake agent speaks on the other ends + * of the same pipes, so no real process is involved. + */ +export function createFakeAcpAgent(opts: { turn?: FakeTurn } = {}) { + const toAgent = new PassThrough(); // driver stdin -> agent input + const fromAgent = new PassThrough(); // agent output -> driver stdout + const stderr = new PassThrough(); + const emitter = new EventEmitter(); + const child = Object.assign(emitter, { + stdin: toAgent, + stdout: fromAgent, + stderr, + killed: false, + kill(signal?: NodeJS.Signals | number) { + if (child.killed) return true; + child.killed = true; + queueMicrotask(() => emitter.emit("exit", null, signal ?? "SIGTERM")); + return true; + }, + }); + // A real child exits when its stdin closes; mirror that so the driver's + // teardown ladder settles without a signal. + toAgent.on("end", () => { + if (!child.killed) { + child.killed = true; + queueMicrotask(() => emitter.emit("exit", 0, null)); + } + }); + + const seen = { + newSession: [] as acp.NewSessionRequest[], + resumeSession: [] as acp.ResumeSessionRequest[], + prompts: [] as string[], + cancels: 0, + closes: 0, + }; + let sessionCounter = 0; + // Assigned below; the agent's prompt handler needs it to push updates. + let connection: acp.AgentSideConnection; + + const agent: acp.Agent = { + async initialize() { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentInfo: { name: "fake-dsh", version: "0.0.0" }, + agentCapabilities: { + mcpCapabilities: { http: true }, + sessionCapabilities: { close: {}, resume: {} }, + }, + authMethods: [], + }; + }, + async authenticate() { + return {}; + }, + async newSession(params) { + seen.newSession.push(params); + return { sessionId: `sess_${++sessionCounter}`, configOptions: [] }; + }, + async resumeSession(params) { + seen.resumeSession.push(params); + return { sessionId: params.sessionId, configOptions: [] }; + }, + async prompt(params) { + const text = params.prompt + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); + seen.prompts.push(text); + const emit = (update: acp.SessionUpdate) => + connection.sessionUpdate({ sessionId: params.sessionId, update }); + const stopReason = opts.turn ? await opts.turn(text, emit) : "end_turn"; + return { stopReason }; + }, + async cancel() { + seen.cancels += 1; + }, + async closeSession() { + seen.closes += 1; + return {}; + }, + }; + + const stream = acp.ndJsonStream( + Writable.toWeb(fromAgent), + Readable.toWeb(toAgent) + ); + connection = new acp.AgentSideConnection(() => agent, stream); + return { child, seen, stderr }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 715a982ca..72fe70dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: apps/server: dependencies: + "@agentclientprotocol/sdk": + specifier: 1.4.0 + version: 1.4.0(zod@4.3.6) "@dispatch/shared": specifier: workspace:* version: link:../../packages/shared @@ -326,6 +329,14 @@ importers: packages/shared: {} packages: + "@agentclientprotocol/sdk@1.4.0": + resolution: + { + integrity: sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==, + } + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + "@alloc/quick-lru@5.2.0": resolution: { @@ -12832,6 +12843,10 @@ packages: } snapshots: + "@agentclientprotocol/sdk@1.4.0(zod@4.3.6)": + dependencies: + zod: 4.3.6 + "@alloc/quick-lru@5.2.0": {} "@ampproject/remapping@2.3.0": From 1ee32986fa5a9cdf0774023da9fc23ea842aa4a0 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:14:06 -0700 Subject: [PATCH 008/254] feat(dsh): fold ACP updates into stream rows and token usage Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/dsh/stream-recorder.ts | 216 ++++++++++++++++++ apps/server/src/agents/dsh/stream-store.ts | 13 ++ apps/server/src/agents/dsh/usage-recorder.ts | 45 ++++ apps/server/test/dsh-stream-recorder.test.ts | 187 +++++++++++++++ apps/server/test/dsh-stream-store.test.ts | 7 + apps/server/test/dsh-usage-recorder.test.ts | 88 +++++++ 6 files changed, 556 insertions(+) create mode 100644 apps/server/src/agents/dsh/stream-recorder.ts create mode 100644 apps/server/src/agents/dsh/usage-recorder.ts create mode 100644 apps/server/test/dsh-stream-recorder.test.ts create mode 100644 apps/server/test/dsh-usage-recorder.test.ts diff --git a/apps/server/src/agents/dsh/stream-recorder.ts b/apps/server/src/agents/dsh/stream-recorder.ts new file mode 100644 index 000000000..a31d6fef6 --- /dev/null +++ b/apps/server/src/agents/dsh/stream-recorder.ts @@ -0,0 +1,216 @@ +import type { DriverEvent, DriverUpdate } from "./driver.js"; +import type { StreamEventRow, StreamStore } from "./stream-store.js"; + +type TextKind = "assistant" | "thought"; + +type OpenText = { row: StreamEventRow; text: string }; + +/** Payload shape of a `tool_call` row; the Chat feed reads these fields. */ +export type ToolPayload = { + toolKind: string; + title: string; + status: "pending" | "in_progress" | "completed" | "failed"; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; +}; + +function textOf(content: { type: string; text?: string } | undefined): string { + return content && content.type === "text" && typeof content.text === "string" + ? content.text + : ""; +} + +function projectLocations( + locations: + | readonly { path: string; line?: number | null }[] + | null + | undefined +): ToolPayload["locations"] { + return (locations ?? []).map((l) => + l.line != null ? { path: l.path, line: l.line } : { path: l.path } + ); +} + +function projectToolContent(content: readonly unknown[] | null | undefined): { + diff: ToolPayload["diff"]; + terminalOutput: string | null; +} { + let diff: ToolPayload["diff"] = null; + let terminalOutput: string | null = null; + for (const item of content ?? []) { + const c = item as { + type: string; + path?: string; + oldText?: string | null; + newText?: string; + content?: { type: string; text?: string }; + }; + if (c.type === "diff" && c.path && typeof c.newText === "string") { + diff = { path: c.path, oldText: c.oldText ?? null, newText: c.newText }; + } else if (c.type === "content" && c.content?.type === "text") { + terminalOutput = (terminalOutput ?? "") + (c.content.text ?? ""); + } + } + return { diff, terminalOutput }; +} + +/** + * Folds driver events into `agent_stream_events` rows. Assistant and + * thought chunks accumulate into one open row each until something else + * interrupts them (a tool call, a settled turn, a process exit); tool calls + * are keyed by toolCallId and rewritten as they settle. One instance serves + * every agent; open-row state is per agent. + */ +export class StreamRecorder { + private readonly open = new Map< + string, + Partial> + >(); + + constructor(private readonly store: StreamStore) {} + + async handle(event: DriverEvent): Promise { + switch (event.type) { + case "update": + return this.handleUpdate(event.agentId, event.update); + case "turn": + if (event.state === "settled") { + await this.closeText(event.agentId); + if (event.error) { + await this.store.append(event.agentId, "status", { + message: event.error, + }); + } + } + return; + case "exit": { + await this.closeText(event.agentId); + if (event.code === 0) return; + const how = + event.code === null ? `signal ${event.signal}` : `code ${event.code}`; + const detail = event.stderrTail ? `: ${event.stderrTail}` : ""; + await this.store.append(event.agentId, "status", { + message: `dsh exited with ${how}${detail}`, + }); + return; + } + } + } + + private async handleUpdate( + agentId: string, + update: DriverUpdate + ): Promise { + switch (update.sessionUpdate) { + case "agent_message_chunk": + return this.appendText(agentId, "assistant", textOf(update.content)); + case "agent_thought_chunk": + return this.appendText(agentId, "thought", textOf(update.content)); + case "tool_call": { + await this.closeText(agentId); + const { diff, terminalOutput } = projectToolContent(update.content); + const payload: ToolPayload = { + toolKind: update.kind ?? "other", + title: update.title, + status: update.status ?? "pending", + locations: projectLocations(update.locations), + diff, + terminalOutput, + }; + await this.store.upsertByKey( + agentId, + "tool_call", + update.toolCallId, + payload + ); + return; + } + case "tool_call_update": { + // An update for a call we never saw start still gets a row, so a + // late-joining feed shows the settled call. + const existing = + (await this.store.getByKey( + agentId, + "tool_call", + update.toolCallId + )) ?? + (await this.store.append( + agentId, + "tool_call", + {}, + update.toolCallId + )); + const prev = existing.payload as Partial; + const projected = update.content + ? projectToolContent(update.content) + : null; + const next: ToolPayload = { + toolKind: update.kind ?? prev.toolKind ?? "other", + title: update.title ?? prev.title ?? "", + status: update.status ?? prev.status ?? "pending", + locations: update.locations + ? projectLocations(update.locations) + : (prev.locations ?? []), + diff: projected?.diff ?? prev.diff ?? null, + terminalOutput: + projected?.terminalOutput ?? prev.terminalOutput ?? null, + }; + await this.store.updatePayload(existing.id, next); + return; + } + default: + return; + } + } + + private async appendText( + agentId: string, + kind: TextKind, + delta: string + ): Promise { + if (!delta) return; + const state = this.open.get(agentId) ?? {}; + const other: TextKind = kind === "assistant" ? "thought" : "assistant"; + if (state[other]) await this.closeText(agentId, other); + let current = state[kind]; + if (!current) { + const row = await this.store.append( + agentId, + kind, + kind === "assistant" + ? { text: delta, streaming: true } + : { text: delta } + ); + current = { row, text: delta }; + } else { + current.text += delta; + await this.store.updatePayload( + current.row.id, + kind === "assistant" + ? { text: current.text, streaming: true } + : { text: current.text } + ); + } + state[kind] = current; + this.open.set(agentId, state); + } + + private async closeText(agentId: string, only?: TextKind): Promise { + const state = this.open.get(agentId); + if (!state) return; + for (const kind of ["assistant", "thought"] as const) { + if (only && kind !== only) continue; + const current = state[kind]; + if (!current) continue; + await this.store.updatePayload( + current.row.id, + kind === "assistant" + ? { text: current.text, streaming: false } + : { text: current.text } + ); + delete state[kind]; + } + if (!state.assistant && !state.thought) this.open.delete(agentId); + } +} diff --git a/apps/server/src/agents/dsh/stream-store.ts b/apps/server/src/agents/dsh/stream-store.ts index 61bfec974..1373eb821 100644 --- a/apps/server/src/agents/dsh/stream-store.ts +++ b/apps/server/src/agents/dsh/stream-store.ts @@ -68,6 +68,19 @@ export class StreamStore { return toRow(result.rows[0]); } + async getByKey( + agentId: string, + kind: StreamEventKind, + key: string + ): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 AND kind = $2 AND key = $3`, + [agentId, kind, key] + ); + return result.rows[0] ? toRow(result.rows[0]) : null; + } + async upsertByKey( agentId: string, kind: StreamEventKind, diff --git a/apps/server/src/agents/dsh/usage-recorder.ts b/apps/server/src/agents/dsh/usage-recorder.ts new file mode 100644 index 000000000..f9839eca4 --- /dev/null +++ b/apps/server/src/agents/dsh/usage-recorder.ts @@ -0,0 +1,45 @@ +import type { Queryable } from "../../chat/store.js"; +import type { DriverEvent } from "./driver.js"; + +/** + * ACP reports cumulative session usage on each prompt response, so every + * settled turn rewrites the totals for (agent, session, model) and bumps the + * turn count. Same table and conflict key the log-scraping harvester uses + * for Claude and Codex, so the token panel needs no new query. + */ +const UPSERT_SQL = `INSERT INTO agent_token_usage + (agent_id, session_id, model, input_tokens, cache_creation_tokens, cache_read_tokens, + output_tokens, message_count, session_start, session_end) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW(), NOW()) + ON CONFLICT (agent_id, session_id, model) + DO UPDATE SET + input_tokens = EXCLUDED.input_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + output_tokens = EXCLUDED.output_tokens, + message_count = agent_token_usage.message_count + 1, + session_end = NOW(), + harvested_at = NOW()`; + +export class UsageRecorder { + constructor(private readonly db: Queryable) {} + + async handle( + event: DriverEvent, + ctx: { sessionId: string; model: string } + ): Promise { + if (event.type !== "turn" || event.state !== "settled" || !event.usage) { + return; + } + const u = event.usage; + await this.db.query(UPSERT_SQL, [ + event.agentId, + ctx.sessionId, + ctx.model, + u.inputTokens ?? 0, + u.cachedWriteTokens ?? 0, + u.cachedReadTokens ?? 0, + u.outputTokens ?? 0, + ]); + } +} diff --git a/apps/server/test/dsh-stream-recorder.test.ts b/apps/server/test/dsh-stream-recorder.test.ts new file mode 100644 index 000000000..f95d4d0ad --- /dev/null +++ b/apps/server/test/dsh-stream-recorder.test.ts @@ -0,0 +1,187 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import type { DriverEvent } from "../src/agents/dsh/driver.js"; +import { StreamRecorder } from "../src/agents/dsh/stream-recorder.js"; +import { StreamStore } from "../src/agents/dsh/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_rec_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'R', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +const chunk = (text: string): DriverEvent => ({ + type: "update", + agentId: A, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, +}); + +const thought = (text: string): DriverEvent => ({ + type: "update", + agentId: A, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }, +}); + +describe("StreamRecorder", () => { + it("accumulates chunks into one assistant row and settles it at turn end", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started" }); + await rec.handle(chunk("Hel")); + await rec.handle(chunk("lo")); + const open = await store.list(A, 10); + expect(open[0].payload).toEqual({ text: "Hello", streaming: true }); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = await store.list(A, 10); + expect(rows).toHaveLength(1); + expect(rows[0].payload).toEqual({ text: "Hello", streaming: false }); + }); + + it("starts a new assistant row after a tool call interrupts the text", async () => { + const rec = new StreamRecorder(store); + await rec.handle(chunk("one")); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read x", + kind: "read", + status: "pending", + locations: [{ path: "/w/x" }], + content: [], + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + content: [{ type: "diff", path: "/w/x", oldText: "a", newText: "b" }], + }, + }); + await rec.handle(chunk("two")); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.kind)).toEqual([ + "assistant", + "tool_call", + "assistant", + ]); + expect(rows[0].payload).toEqual({ text: "one", streaming: false }); + expect(rows[1].key).toBe("c1"); + expect(rows[1].payload).toEqual({ + toolKind: "read", + title: "Read x", + status: "completed", + locations: [{ path: "/w/x" }], + diff: { path: "/w/x", oldText: "a", newText: "b" }, + terminalOutput: null, + }); + expect(rows[2].payload).toEqual({ text: "two", streaming: true }); + }); + + it("keeps thoughts in their own rows, separate from assistant text", async () => { + const rec = new StreamRecorder(store); + await rec.handle(thought("plan")); + await rec.handle(thought("ning")); + await rec.handle(chunk("Done.")); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => [r.kind, r.payload.text])).toEqual([ + ["thought", "planning"], + ["assistant", "Done."], + ]); + }); + + it("captures terminal output from content blocks on a tool call update", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "sh1", + title: "pnpm test", + kind: "execute", + status: "in_progress", + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "sh1", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "12 passed\n" } }, + ], + }, + }); + const rows = await store.list(A, 10); + expect(rows[0].payload).toMatchObject({ + toolKind: "execute", + status: "completed", + terminalOutput: "12 passed\n", + }); + }); + + it("records a settled error and a crash as status rows", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + error: "no API key", + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 1, + signal: null, + stderrTail: "boom", + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 0, + signal: null, + stderrTail: "", + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.payload.message)).toEqual([ + "no API key", + "dsh exited with code 1: boom", + ]); + }); +}); diff --git a/apps/server/test/dsh-stream-store.test.ts b/apps/server/test/dsh-stream-store.test.ts index e288895b2..cb37caf22 100644 --- a/apps/server/test/dsh-stream-store.test.ts +++ b/apps/server/test/dsh-stream-store.test.ts @@ -46,6 +46,13 @@ describe("StreamStore", () => { expect(second.payload).toEqual({ status: "completed" }); }); + it("reads a keyed row without touching it", async () => { + await store.upsertByKey(A, "tool_call", "call_2", { status: "pending" }); + const row = await store.getByKey(A, "tool_call", "call_2"); + expect(row?.payload).toEqual({ status: "pending" }); + expect(await store.getByKey(A, "tool_call", "missing")).toBeNull(); + }); + it("returns the latest row of a kind and updates a payload in place", async () => { const row = await store.append(A, "assistant", { text: "a" }); await store.updatePayload(row.id, { text: "ab" }); diff --git a/apps/server/test/dsh-usage-recorder.test.ts b/apps/server/test/dsh-usage-recorder.test.ts new file mode 100644 index 000000000..a0e8709e1 --- /dev/null +++ b/apps/server/test/dsh-usage-recorder.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import type { DriverEvent } from "../src/agents/dsh/driver.js"; +import { UsageRecorder } from "../src/agents/dsh/usage-recorder.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +const A = "agt_usage_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'U', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_token_usage WHERE agent_id = $1", [A]); +}); + +const settled = (input: number, output: number): DriverEvent => ({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + usage: { + totalTokens: input + output, + inputTokens: input, + outputTokens: output, + thoughtTokens: 0, + cachedReadTokens: 5, + cachedWriteTokens: 1, + }, +}); + +describe("UsageRecorder", () => { + it("upserts cumulative totals per agent, session, and model", async () => { + const rec = new UsageRecorder(pool); + const ctx = { sessionId: "sess_1", model: "openai/gpt-5.2" }; + await rec.handle(settled(100, 10), ctx); + await rec.handle(settled(250, 40), ctx); + const rows = await pool.query( + `SELECT input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, message_count + FROM agent_token_usage + WHERE agent_id = $1 AND session_id = $2 AND model = $3`, + [A, "sess_1", "openai/gpt-5.2"] + ); + expect(rows.rows).toEqual([ + { + input_tokens: 250, + output_tokens: 40, + cache_read_tokens: 5, + cache_creation_tokens: 1, + message_count: 2, + }, + ]); + }); + + it("ignores turns without usage and non-turn events", async () => { + const rec = new UsageRecorder(pool); + const ctx = { sessionId: "s", model: "m" }; + await rec.handle({ type: "turn", agentId: A, state: "started" }, ctx); + await rec.handle( + { type: "turn", agentId: A, state: "settled", stopReason: "end_turn" }, + ctx + ); + await rec.handle( + { + type: "update", + agentId: A, + update: { sessionUpdate: "usage_update", used: 10, size: 100 }, + }, + ctx + ); + const rows = await pool.query( + `SELECT 1 FROM agent_token_usage WHERE agent_id = $1`, + [A] + ); + expect(rows.rowCount).toBe(0); + }); +}); From f9974f0e5d6178b94d63db783f9be88d6c287ae4 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:18:13 -0700 Subject: [PATCH 009/254] feat(dsh): supervisor starts, prompts, and stops dsh from the agent lifecycle The manager starts the ACP child when a dsh agent's setup completes, resumes it on restart, and stops it on stop and archive. The activity monitor leaves dsh agents alone; their status comes from the stream. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/activity-monitor.ts | 5 +- apps/server/src/agents/archive.ts | 4 + apps/server/src/agents/dsh/persona.ts | 49 ++++++ apps/server/src/agents/dsh/supervisor.ts | 176 +++++++++++++++++++++ apps/server/src/agents/manager.ts | 88 ++++++++++- apps/server/src/server.ts | 15 ++ apps/server/test/dsh-persona.test.ts | 51 ++++++ apps/server/test/dsh-supervisor.test.ts | 143 +++++++++++++++++ 8 files changed, 524 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/agents/dsh/persona.ts create mode 100644 apps/server/src/agents/dsh/supervisor.ts create mode 100644 apps/server/test/dsh-persona.test.ts create mode 100644 apps/server/test/dsh-supervisor.test.ts diff --git a/apps/server/src/agents/activity-monitor.ts b/apps/server/src/agents/activity-monitor.ts index e079a1af6..f5ac945f5 100644 --- a/apps/server/src/agents/activity-monitor.ts +++ b/apps/server/src/agents/activity-monitor.ts @@ -83,7 +83,10 @@ export function createActivityMonitor( FROM agents WHERE deleted_at IS NULL AND status = 'running' - AND tmux_session IS NOT NULL` + AND tmux_session IS NOT NULL + -- dsh agents derive working/idle from their ACP stream; their + -- pane is a plain shell whose quiet would only demote them. + AND type <> 'dsh'` ); const runningIds = new Set(); diff --git a/apps/server/src/agents/archive.ts b/apps/server/src/agents/archive.ts index 550cf40ab..68ec474ae 100644 --- a/apps/server/src/agents/archive.ts +++ b/apps/server/src/agents/archive.ts @@ -25,6 +25,8 @@ export type ArchiveDeps = { getAgent: (id: string) => Promise; getRequiredAgent: (id: string) => Promise; harvestAgentTokens: (agent: AgentRecord) => Promise; + /** Stop a protocol-driven harness (dsh) that lives outside the tmux pane. */ + stopHarness?: (agent: AgentRecord) => Promise; setAgentStatus: ( id: string, status: AgentStatus, @@ -261,6 +263,7 @@ export async function executeArchive( "Stop hook failed during archive; continuing" ) ); + await deps.stopHarness?.(agent); if (agent.tmuxSession && (await runtime.hasSession(agent.tmuxSession))) { await runtime.stopSession(agent.tmuxSession, true); } @@ -423,6 +426,7 @@ export async function deleteAgentDirect( "Stop hook failed during delete; continuing" ) ); + await deps.stopHarness?.(agent); if (agent.tmuxSession && sessionExists) { await runtime.stopSession(agent.tmuxSession, true); } diff --git a/apps/server/src/agents/dsh/persona.ts b/apps/server/src/agents/dsh/persona.ts new file mode 100644 index 000000000..ae94794c3 --- /dev/null +++ b/apps/server/src/agents/dsh/persona.ts @@ -0,0 +1,49 @@ +import type { AgentRecord } from "@dispatch/shared"; + +import { + buildLaunchGuidance, + normalizeAgentArgsForType, +} from "../tmux/command-builder.js"; + +/** + * The system-prompt persona for a dsh agent. CLI agents get the same pieces + * as separate `--append-system-prompt` flags; dsh takes one persona string + * through the overlay (see overlay.ts), so this joins them: the Dispatch + * launch guidance first, then the persona brief a review launch stored in + * `agentArgs`, or the active personality for a standard agent. + * + * `{{model}}` and `{{cwd}}` are dsh prompt variables; the guidance text does + * not use them, so nothing here needs escaping. + */ +export function buildDshPersona(input: { + agent: Pick< + AgentRecord, + "id" | "type" | "agentArgs" | "persona" | "autoReview" + >; + personalityPrompt: string | null; + trimmedGuidance: boolean; + chatSurface: boolean; + suggestSessionRename: boolean; +}): string { + const { agent } = input; + const guidance = buildLaunchGuidance(agent.id, { + agentType: agent.type, + suggestSessionRename: input.suggestSessionRename, + autoReview: !agent.persona && agent.autoReview, + trimmedGuidance: input.trimmedGuidance, + chatSurface: input.chatSurface, + }); + // A persona launch stores its brief as `--append-system-prompt ` + // in agentArgs. Normalising "as codex" is the branch that extracts that + // pair (the claude branch passes args through untouched). + const { appendedSystemPrompt } = normalizeAgentArgsForType( + "codex", + agent.agentArgs ?? [] + ); + const sections = [guidance.trim()]; + if (appendedSystemPrompt?.trim()) sections.push(appendedSystemPrompt.trim()); + else if (input.personalityPrompt?.trim()) { + sections.push(input.personalityPrompt.trim()); + } + return sections.join("\n\n"); +} diff --git a/apps/server/src/agents/dsh/supervisor.ts b/apps/server/src/agents/dsh/supervisor.ts new file mode 100644 index 000000000..2a4940d6d --- /dev/null +++ b/apps/server/src/agents/dsh/supervisor.ts @@ -0,0 +1,176 @@ +import path from "node:path"; +import type { Pool } from "pg"; +import type { AgentLatestEventType, AgentRecord } from "@dispatch/shared"; + +import { createAgentMcpToken } from "../../auth.js"; +import type { AppConfig } from "../../config.js"; +import { dispatchMcpUrl } from "../tmux/mcp-url.js"; +import { DshDriver, type DriverEvent, type DriverLogger } from "./driver.js"; +import { writeOverlay } from "./overlay.js"; +import { StreamRecorder } from "./stream-recorder.js"; +import { StreamStore } from "./stream-store.js"; +import { UsageRecorder } from "./usage-recorder.js"; + +export type SupervisorDeps = { + pool: Pool; + config: Pick; + logger: DriverLogger; + /** Injectable for tests; defaults to a driver over the real `dsh` binary. */ + driver?: DshDriver; + getAgent: (id: string) => Promise; + setCliSessionId: (id: string, sessionId: string) => Promise; + setLatestEvent: ( + id: string, + input: { type: AgentLatestEventType; message: string } + ) => Promise; + /** ChatService.publishChanged: the feed re-reads after each stream write. */ + publishChat: (agentId: string) => void; + /** Full persona text for the overlay (see persona.ts). */ + personaPromptFor: (agent: AgentRecord) => Promise; +}; + +/** + * Environment the dsh child inherits from the server. Provider keys ride + * along when set; everything else stays out so the child sees a clean shell. + */ +const PASSTHROUGH_ENV = [ + "PATH", + "HOME", + "SHELL", + "LANG", + "LC_ALL", + "TMPDIR", + "DEEPSEEK_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", +]; + +const MESSAGE_MAX = 200; + +/** + * Glue between the agent lifecycle and the ACP driver: starts dsh when an + * agent's setup completes, turns prompts into turns with working/idle status + * around them, folds the stream into the store and usage table, and stops + * the child when the agent stops. + */ +export class DshSupervisor { + private readonly driver: DshDriver; + private readonly streams: StreamRecorder; + private readonly usage: UsageRecorder; + private readonly context = new Map< + string, + { sessionId: string; model: string } + >(); + + constructor(private readonly deps: SupervisorDeps) { + this.driver = + deps.driver ?? + new DshDriver({ + dshBin: deps.config.dshBin, + dshHome: deps.config.dshHome, + logger: deps.logger, + }); + this.streams = new StreamRecorder(new StreamStore(deps.pool)); + this.usage = new UsageRecorder(deps.pool); + this.driver.onEvent((event) => { + void this.onEvent(event); + }); + } + + isRunning(agentId: string): boolean { + return this.driver.isRunning(agentId); + } + + async start(agentId: string): Promise { + const agent = await this.deps.getAgent(agentId); + if (!agent || agent.type !== "dsh") { + throw new Error(`${agentId} is not a dsh agent`); + } + const overlayPath = await writeOverlay( + path.join(this.deps.config.dshHome, "overlays"), + agentId, + { + model: agent.model ?? null, + persona: await this.deps.personaPromptFor(agent), + } + ); + const env: NodeJS.ProcessEnv = {}; + for (const key of PASSTHROUGH_ENV) { + if (process.env[key]) env[key] = process.env[key]; + } + env.DISPATCH_AGENT_ID = agentId; + const { sessionId } = await this.driver.start({ + agentId, + cwd: agent.cwd, + overlayPath, + mcp: { + url: dispatchMcpUrl(this.deps.config as AppConfig, agentId), + token: createAgentMcpToken(this.deps.config.authToken, agentId), + }, + sessionId: agent.cliSessionId ?? null, + env, + }); + this.context.set(agentId, { sessionId, model: agent.model ?? "default" }); + await this.deps.setCliSessionId(agentId, sessionId); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: agent.cliSessionId + ? "dsh session resumed." + : "dsh session started.", + }); + } + + /** Runs one turn. Resolves after the turn settles; never throws. */ + async prompt(agentId: string, text: string): Promise { + await this.deps.setLatestEvent(agentId, { + type: "working", + message: "Working on the latest message.", + }); + try { + await this.driver.prompt(agentId, text); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: "Turn finished.", + }); + } catch (err) { + const message = (err as Error).message; + this.deps.logger.warn({ err, agentId }, "dsh prompt failed"); + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: `Turn failed: ${message}`.slice(0, MESSAGE_MAX), + }); + } + } + + async cancel(agentId: string): Promise { + if (!this.driver.isRunning(agentId)) return; + await this.driver.cancel(agentId); + } + + async stop(agentId: string): Promise { + await this.driver.stop(agentId); + this.context.delete(agentId); + } + + private async onEvent(event: DriverEvent): Promise { + try { + await this.streams.handle(event); + const ctx = this.context.get(event.agentId); + if (ctx) await this.usage.handle(event, ctx); + this.deps.publishChat(event.agentId); + if (event.type === "exit" && event.code !== 0 && ctx) { + // ctx still set means we did not stop it ourselves. + this.context.delete(event.agentId); + await this.deps.setLatestEvent(event.agentId, { + type: "blocked", + message: `dsh exited (${event.code ?? event.signal ?? "unknown"}).`, + }); + } + } catch (err) { + this.deps.logger.warn( + { err, agentId: event.agentId }, + "dsh event handling failed" + ); + } + } +} diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 49c79a8fc..fc56fb94d 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -28,6 +28,8 @@ import { isChatSurfaceEnabled } from "../chat-surface-settings.js"; import { findCodexSessionId } from "./codex-sessions.js"; import { harvestTokenUsage } from "./token-harvester.js"; import { errorMessage } from "../shared/lib/error-message.js"; +import { buildDshPersona } from "./dsh/persona.js"; +import type { DshSupervisor } from "./dsh/supervisor.js"; import { beginArchive as beginArchiveImpl, executeArchive as executeArchiveImpl, @@ -307,6 +309,7 @@ export class AgentManager { private readonly runtime: AgentRuntime; private readonly reconciler: Reconciler; private diffStatsRefresher: DiffStatsRefresherHandle | null = null; + private dshSupervisor: DshSupervisor | null = null; private launchContextRecorder: LaunchContextRecorder | null = null; private readonly agentCreatedListeners: Array<(agent: AgentRecord) => void> = []; @@ -331,6 +334,47 @@ export class AgentManager { }); } + /** + * Inject the dsh supervisor. Wired post-construction like the other + * collaborators; without it a dsh agent fails setup loudly rather than + * sitting in a shell with no harness behind it. + */ + attachDshSupervisor(supervisor: DshSupervisor): void { + this.dshSupervisor = supervisor; + } + + getDshSupervisor(): DshSupervisor | null { + return this.dshSupervisor; + } + + /** The system-prompt persona a dsh agent launches with (see dsh/persona.ts). */ + async buildDshPersonaFor(agent: AgentRecord): Promise { + const personality = + agent.persona || agent.role === "assisted_update" + ? null + : await getActivePersonality(this.pool); + const { trimmedGuidance, chatSurface } = await readLaunchGuidanceFlags( + this.pool + ); + return buildDshPersona({ + agent, + personalityPrompt: personality?.prompt ?? null, + trimmedGuidance, + chatSurface, + suggestSessionRename: shouldSuggestSessionRename(agent.name, agent.id, { + persona: agent.persona, + }), + }); + } + + /** Record the harness session id a dsh agent runs under (for resume). */ + async setCliSessionId(id: string, cliSessionId: string): Promise { + await this.pool.query( + `UPDATE agents SET cli_session_id = $2, updated_at = NOW() WHERE id = $1`, + [id, cliSessionId] + ); + } + /** Register a callback invoked after every upsertLatestEvent. */ onLatestEvent(listener: AgentEventListener): void { this.eventBus.subscribe(listener); @@ -1092,12 +1136,32 @@ export class AgentManager { // upsert) carries the populated context. await this.populateGitContext(id); - await this.setSystemLatestEvent( - id, - agent.type === "terminal" - ? { type: "idle", message: "Terminal session started." } - : { type: "idle", message: "Session started." } - ); + if (agent.type === "dsh") { + // The pane is only a shell; the harness is the ACP child the + // supervisor starts now that the worktree exists. + if (!this.dshSupervisor) { + throw new AgentError("dsh supervisor is not attached.", 500); + } + try { + await this.dshSupervisor.start(id); + } catch (error) { + const message = errorMessage(error); + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `dsh failed to start: ${message}`.slice(0, 200), + metadata: { source: "system", phase: "start" }, + }); + throw new AgentError(`dsh failed to start: ${message}`, 500); + } + } else { + await this.setSystemLatestEvent( + id, + agent.type === "terminal" + ? { type: "idle", message: "Terminal session started." } + : { type: "idle", message: "Session started." } + ); + } // Clean up setup script const setupScriptPath = `/tmp/dispatch_setup_${id}.sh`; @@ -1252,6 +1316,14 @@ export class AgentManager { // predate inline-populate still get a fresh context (and any drift // from external git activity gets picked up at start time). await this.populateGitContext(id); + if (agent.type === "dsh") { + if (!this.dshSupervisor) { + throw new Error("dsh supervisor is not attached."); + } + // Resumes the stored session id; the supervisor sets the idle event. + await this.dshSupervisor.start(id); + return (await this.getAgent(id)) as AgentRecord; + } await this.setSystemLatestEvent( id, agent.type === "terminal" @@ -1339,6 +1411,7 @@ export class AgentManager { ); try { + if (agent.type === "dsh") await this.dshSupervisor?.stop(id); if (tmuxSession && (await this.runtime.hasSession(tmuxSession))) { await this.runtime.stopSession(tmuxSession, force); } @@ -1818,6 +1891,9 @@ export class AgentManager { getAgent: (id) => this.getAgent(id), getRequiredAgent: (id) => this.getRequiredAgent(id), harvestAgentTokens: (agent) => this.harvestAgentTokens(agent), + stopHarness: async (agent) => { + if (agent.type === "dsh") await this.dshSupervisor?.stop(agent.id); + }, setAgentStatus: (id, status, lastError, tmuxSession) => this.setAgentStatus(id, status, lastError, tmuxSession), setArchivePhase: (id, phase) => this.setArchivePhase(id, phase), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2e5ea79f6..a309d5749 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,6 +18,7 @@ import Fastify from "fastify"; import * as z from "zod/v4"; import { AgentManager } from "./agents/manager.js"; +import { DshSupervisor } from "./agents/dsh/supervisor.js"; import type { AgentRecord } from "./agents/manager.js"; import { validateSession, @@ -450,6 +451,20 @@ const chatService = new ChatService({ log: app.log, }); agentManager.attachLaunchContextRecorder(chatService); +const dshSupervisor = new DshSupervisor({ + pool, + config, + logger: app.log, + getAgent: (agentId) => agentManager.getAgent(agentId), + setCliSessionId: (agentId, sessionId) => + agentManager.setCliSessionId(agentId, sessionId), + setLatestEvent: async (agentId, input) => { + await agentManager.upsertLatestEvent(agentId, input); + }, + publishChat: (agentId) => chatService.publishChanged(agentId), + personaPromptFor: (agent) => agentManager.buildDshPersonaFor(agent), +}); +agentManager.attachDshSupervisor(dshSupervisor); jobService.setBrainStore(brainStore); const mcpHandlers = createMcpHandlers({ pool, diff --git a/apps/server/test/dsh-persona.test.ts b/apps/server/test/dsh-persona.test.ts new file mode 100644 index 000000000..58edbd987 --- /dev/null +++ b/apps/server/test/dsh-persona.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { buildDshPersona } from "../src/agents/dsh/persona.js"; + +const base = { + id: "agt_p", + type: "dsh" as const, + agentArgs: [] as string[], + persona: null, + autoReview: false, +}; + +describe("buildDshPersona", () => { + it("starts with the Dispatch launch guidance", () => { + const text = buildDshPersona({ + agent: base, + personalityPrompt: null, + trimmedGuidance: false, + chatSurface: false, + suggestSessionRename: false, + }); + expect(text).toContain("dispatch_event"); + }); + + it("appends the active personality for a standard agent", () => { + const text = buildDshPersona({ + agent: base, + personalityPrompt: "Be terse.", + trimmedGuidance: false, + chatSurface: true, + suggestSessionRename: false, + }); + expect(text.endsWith("Be terse.")).toBe(true); + }); + + it("prefers the persona brief stored in agentArgs over a personality", () => { + const text = buildDshPersona({ + agent: { + ...base, + persona: "security-review", + agentArgs: ["--append-system-prompt", "You review for security."], + }, + personalityPrompt: "Be terse.", + trimmedGuidance: false, + chatSurface: false, + suggestSessionRename: false, + }); + expect(text).toContain("You review for security."); + expect(text).not.toContain("Be terse."); + }); +}); diff --git a/apps/server/test/dsh-supervisor.test.ts b/apps/server/test/dsh-supervisor.test.ts new file mode 100644 index 000000000..03146d95d --- /dev/null +++ b/apps/server/test/dsh-supervisor.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DshDriver } from "../src/agents/dsh/driver.js"; +import { DshSupervisor } from "../src/agents/dsh/supervisor.js"; +import { createFakeAcpAgent, type FakeTurn } from "./helpers/fake-acp-agent.js"; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +let home = ""; +afterEach(async () => { + if (home) await rm(home, { recursive: true, force: true }); + home = ""; +}); + +async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { + home = await mkdtemp(path.join(os.tmpdir(), "dsh-sup-")); + const fake = createFakeAcpAgent({ turn: opts.turn }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: home, + spawn: () => fake.child, + logger, + }); + const query = vi.fn(async () => ({ rows: [], rowCount: 0 })); + const events: { type: string; message: string }[] = []; + const deps = { + pool: { query } as never, + config: { + dshBin: "dsh", + dshHome: home, + port: 1, + tls: null, + authToken: "secret", + }, + logger, + driver, + getAgent: vi.fn(async (id: string) => ({ + id, + type: "dsh", + cwd: "/tmp/w", + model: "openai/gpt-5.2", + cliSessionId: opts.cliSessionId ?? null, + })) as never, + setCliSessionId: vi.fn(async () => {}), + setLatestEvent: vi.fn( + async (_id: string, input: { type: string; message: string }) => { + events.push(input); + } + ), + publishChat: vi.fn(), + personaPromptFor: vi.fn(async () => "PERSONA TEXT"), + }; + const sup = new DshSupervisor(deps); + return { fake, deps, events, sup, query }; +} + +describe("DshSupervisor", () => { + it("start writes the overlay, records the session id, and marks idle", async () => { + const { sup, deps, fake, events } = await build(); + await sup.start("agt_1"); + expect(deps.setCliSessionId).toHaveBeenCalledWith("agt_1", "sess_1"); + expect(fake.seen.newSession[0].cwd).toBe("/tmp/w"); + expect(fake.seen.newSession[0].mcpServers?.[0]).toMatchObject({ + type: "http", + name: "dispatch", + url: "http://127.0.0.1:1/api/mcp/agt_1", + }); + const overlay = await readFile( + path.join(home, "overlays", "agt_1.patch.yml"), + "utf8" + ); + expect(overlay).toContain("PERSONA TEXT"); + expect(overlay).toContain("gpt-5.2"); + expect(events.at(-1)).toEqual({ + type: "idle", + message: "dsh session started.", + }); + expect(sup.isRunning("agt_1")).toBe(true); + await sup.stop("agt_1"); + }); + + it("resumes a stored session id", async () => { + const { sup, fake, events } = await build({ cliSessionId: "sess_old" }); + await sup.start("agt_1"); + expect(fake.seen.resumeSession[0]?.sessionId).toBe("sess_old"); + expect(events.at(-1)?.message).toBe("dsh session resumed."); + await sup.stop("agt_1"); + }); + + it("prompt marks working, then idle when the turn settles, and publishes the chat", async () => { + const { sup, events, deps, query } = await build({ + turn: async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "ok" }, + }); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.map((e) => e.type)).toEqual(["idle", "working", "idle"]); + expect(deps.publishChat).toHaveBeenCalledWith("agt_1"); + // The stream recorder wrote through the pool. + expect(query).toHaveBeenCalled(); + await sup.stop("agt_1"); + }); + + it("prompt failure surfaces as idle with the error message", async () => { + const { sup, events } = await build({ + turn: async () => { + throw new Error("no API key for provider route"); + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.at(-1)).toMatchObject({ + type: "idle", + message: expect.stringContaining("no API key"), + }); + await sup.stop("agt_1"); + }); + + it("refuses to start a non-dsh agent", async () => { + const { sup, deps } = await build(); + deps.getAgent.mockResolvedValueOnce({ + id: "agt_c", + type: "claude", + cwd: "/tmp", + model: null, + cliSessionId: null, + } as never); + await expect(sup.start("agt_c")).rejects.toThrow(/not a dsh agent/); + }); +}); From 69ddd844b54f16181bec9f2b8e967b9d69e45dd2 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:18:34 -0700 Subject: [PATCH 010/254] feat(dsh): deliver prompts and messages to dsh over ACP instead of the pane Co-Authored-By: Claude Fable 5.1 --- apps/server/src/server/agent-prompts.ts | 16 ++++++++ apps/server/test/agent-prompts.test.ts | 49 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/apps/server/src/server/agent-prompts.ts b/apps/server/src/server/agent-prompts.ts index 7e52083d6..2d359360d 100644 --- a/apps/server/src/server/agent-prompts.ts +++ b/apps/server/src/server/agent-prompts.ts @@ -36,6 +36,22 @@ export function createPromptInjector( prompt, opts = {} ) => { + const agent = await agentManager.getAgent(agentId); + if (agent?.type === "dsh") { + // dsh has no pane to paste into: the prompt becomes an ACP turn. The + // turn runs in the background; "delivered" means accepted, which is + // what pane injection promises for CLI agents too. + const supervisor = agentManager.getDshSupervisor(); + if (!supervisor || !supervisor.isRunning(agentId)) { + throw new Error( + "dsh is not running for this agent — prompt cannot be delivered." + ); + } + supervisor.prompt(agentId, prompt).catch((error) => { + appLog.warn({ err: error, agentId }, "dsh turn failed"); + }); + return { held: false, delivery: Promise.resolve() }; + } const access = await agentManager.getTerminalAccess(agentId); if (access.mode !== "tmux") { throw new Error( diff --git a/apps/server/test/agent-prompts.test.ts b/apps/server/test/agent-prompts.test.ts index 8aa632557..433cfde6d 100644 --- a/apps/server/test/agent-prompts.test.ts +++ b/apps/server/test/agent-prompts.test.ts @@ -23,6 +23,8 @@ function build(opts: { tmux?: boolean; quietMs?: number } = {}) { maxWaitMs: 1_000, }); const agentManager = { + getAgent: vi.fn(async (id: string) => ({ id, type: "claude" })), + getDshSupervisor: vi.fn(() => null), getTerminalAccess: vi.fn(async () => opts.tmux === false ? { mode: "inert" as const, message: "No pane." } @@ -125,3 +127,50 @@ describe("injectAgentPrompt (wrapper)", () => { ); }); }); + +describe("enqueueAgentPrompt for dsh agents", () => { + it("routes the prompt to the supervisor instead of the pane", async () => { + const prompt = vi.fn(async () => {}); + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); + agentManager.getDshSupervisor.mockReturnValue({ + isRunning: () => true, + prompt, + } as never); + const { held, delivery } = await enqueueAgentPrompt("agt_d", "hello dsh"); + expect(held).toBe(false); + await delivery; + expect(prompt).toHaveBeenCalledWith("agt_d", "hello dsh"); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it("fails loudly when the dsh process is not running", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); + agentManager.getDshSupervisor.mockReturnValue({ + isRunning: () => false, + prompt: vi.fn(), + } as never); + await expect(enqueueAgentPrompt("agt_d", "x")).rejects.toThrow( + /dsh is not running/ + ); + }); + + it("logs a failed turn without rejecting the enqueue", async () => { + const { enqueueAgentPrompt, agentManager, log } = build(); + agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); + agentManager.getDshSupervisor.mockReturnValue({ + isRunning: () => true, + prompt: vi.fn(async () => { + throw new Error("turn exploded"); + }), + } as never); + const { delivery } = await enqueueAgentPrompt("agt_d", "x"); + await delivery; + await new Promise((r) => setTimeout(r, 0)); + expect(log.warn).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "agt_d" }), + "dsh turn failed" + ); + }); +}); From 8cb3dc5c76b32d57209a7b7b331ec4a90b256543 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:21:01 -0700 Subject: [PATCH 011/254] feat(dsh): render assistant text and tool activity in the Chat tab Co-Authored-By: Claude Fable 5.1 --- .../src/components/app/chat/chat-entries.tsx | 131 +++++++++++++++++- .../components/app/chat/chat-feed.test.tsx | 62 +++++++++ .../web/src/components/app/chat/chat-feed.tsx | 20 +++ 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 1d3b48e89..1f02c65e1 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -1,6 +1,8 @@ -import { memo, type ReactNode } from "react"; +import { memo, type ReactNode, useState } from "react"; import type { + ChatActivityEntry, ChatAgentMessageEntry, + ChatAssistantEntry, ChatAttachment, ChatMediaEntry, ChatMessage, @@ -1178,3 +1180,130 @@ export function ReviewEntryView({ ); } + +// ── Stream-driven harness entries (dsh over ACP) ───────────────────────── + +/** Assistant text from the harness stream: the agent's own post. */ +export function AssistantEntryView({ + entry, + grouped, + rule = false, + ctx, +}: { + entry: ChatAssistantEntry; + grouped: boolean; + rule?: boolean; + ctx: FeedContext; +}): JSX.Element { + return ( + +
+ {entry.text} + {entry.streaming ? ( + + ) : null} +
+
+ ); +} + +const ACTIVITY_STATUS_CLASS: Record = { + pending: "bg-muted-foreground/40", + in_progress: "bg-status-working", + completed: "bg-status-done", + failed: "bg-status-blocked", +}; + +/** A line-by-line diff for an activity card; enough for a prototype view. */ +export function renderUnifiedDiff(oldText: string, newText: string): string { + const a = oldText.split("\n"); + const b = newText.split("\n"); + const out: string[] = []; + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + if (a[i] === b[i]) { + out.push(` ${a[i] ?? ""}`); + continue; + } + if (i < a.length) out.push(`- ${a[i]}`); + if (i < b.length) out.push(`+ ${b[i]}`); + } + return out.join("\n"); +} + +/** + * One tool call from the harness stream: a compact row under the agent's + * posts, expandable when it carries a diff or terminal output. + */ +export function ActivityEntryView({ + entry, + grouped, + rule = false, +}: { + entry: ChatActivityEntry; + grouped: boolean; + rule?: boolean; + ctx: FeedContext; +}): JSX.Element { + const [open, setOpen] = useState(false); + const expandable = entry.diff !== null || entry.terminalOutput !== null; + const location = entry.locations[0]?.path ?? null; + return ( +
+ + {open && entry.diff ? ( +
+          {renderUnifiedDiff(entry.diff.oldText ?? "", entry.diff.newText)}
+        
+ ) : null} + {open && entry.terminalOutput ? ( +
+          {entry.terminalOutput}
+        
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index 8858a2670..765b61d18 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -1376,3 +1376,65 @@ describe("ChatFeed enter animation", () => { expect(enterOf(edited)).not.toBeNull(); }); }); + +describe("stream entries", () => { + it("renders assistant text and a tool activity row", () => { + renderFeed([ + { + type: "assistant", + id: "stream:1", + text: "I will read the file.", + streaming: false, + at: "2026-09-04T10:00:00.000Z", + }, + { + type: "activity", + id: "stream:2", + toolKind: "read", + title: "Read README.md", + status: "completed", + locations: [{ path: "/w/README.md" }], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:01.000Z", + }, + ]); + expect(screen.getByText("I will read the file.")).toBeTruthy(); + expect(screen.getByText("Read README.md")).toBeTruthy(); + expect(screen.getByLabelText("completed")).toBeTruthy(); + }); + + it("shows a streaming indicator while an assistant message is open", () => { + renderFeed([ + { + type: "assistant", + id: "stream:1", + text: "Thinking", + streaming: true, + at: "2026-09-04T10:00:00.000Z", + }, + ]); + expect(screen.getByLabelText("streaming")).toBeTruthy(); + }); + + it("expands an activity row to show its diff", () => { + renderFeed([ + { + type: "activity", + id: "stream:3", + toolKind: "edit", + title: "Edit index.ts", + status: "completed", + locations: [{ path: "/w/index.ts", line: 3 }], + diff: { path: "/w/index.ts", oldText: "a\nb", newText: "a\nc" }, + terminalOutput: null, + at: "2026-09-04T10:00:02.000Z", + }, + ]); + fireEvent.click(screen.getByRole("button", { name: /Edit index.ts/ })); + const row = screen.getByTestId("chat-activity"); + expect(row.getAttribute("data-status")).toBe("completed"); + expect(row.textContent).toContain("- b"); + expect(row.textContent).toContain("+ c"); + }); +}); diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 6ba73b3f0..7eca1dde7 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -16,6 +16,8 @@ import { dayLabel, MediaEntryView, reviewAuthor, + ActivityEntryView, + AssistantEntryView, ReviewEntryView, StatusLine, } from "@/components/app/chat/chat-entries"; @@ -397,6 +399,24 @@ export function ChatFeed({ ctx={ctx} /> ); + case "assistant": + return ( + + ); + case "activity": + return ( + + ); } })(); return ( From 24cef19ca52c832b23798751b95192901f20fddb Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:28:28 -0700 Subject: [PATCH 012/254] fix(dsh): serialize stream event handling per agent Driver events were handled concurrently, so two appends for one agent could compute the same seq and one died on the unique index, and chunk accumulation raced its own open-row state. One promise chain per agent keeps order; prompt() waits for the chain to drain before reporting idle. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/dsh/supervisor.ts | 22 ++++++- apps/server/test/dsh-supervisor.test.ts | 73 +++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/server/src/agents/dsh/supervisor.ts b/apps/server/src/agents/dsh/supervisor.ts index 2a4940d6d..c2fcfb573 100644 --- a/apps/server/src/agents/dsh/supervisor.ts +++ b/apps/server/src/agents/dsh/supervisor.ts @@ -61,6 +61,13 @@ export class DshSupervisor { string, { sessionId: string; model: string } >(); + /** + * One writer per agent. Driver events arrive faster than their DB writes + * settle; handled concurrently, two appends compute the same seq and one + * dies on the unique index, and chunk accumulation sees stale open-row + * state. Chaining each agent's events keeps order and the invariant. + */ + private readonly queues = new Map>(); constructor(private readonly deps: SupervisorDeps) { this.driver = @@ -73,7 +80,14 @@ export class DshSupervisor { this.streams = new StreamRecorder(new StreamStore(deps.pool)); this.usage = new UsageRecorder(deps.pool); this.driver.onEvent((event) => { - void this.onEvent(event); + const prior = this.queues.get(event.agentId) ?? Promise.resolve(); + const next = prior.then(() => this.onEvent(event)); + this.queues.set(event.agentId, next); + void next.finally(() => { + if (this.queues.get(event.agentId) === next) { + this.queues.delete(event.agentId); + } + }); }); } @@ -128,6 +142,7 @@ export class DshSupervisor { }); try { await this.driver.prompt(agentId, text); + await this.drained(agentId); await this.deps.setLatestEvent(agentId, { type: "idle", message: "Turn finished.", @@ -152,6 +167,11 @@ export class DshSupervisor { this.context.delete(agentId); } + /** Resolves once every event queued so far for the agent has been handled. */ + private async drained(agentId: string): Promise { + await this.queues.get(agentId); + } + private async onEvent(event: DriverEvent): Promise { try { await this.streams.handle(event); diff --git a/apps/server/test/dsh-supervisor.test.ts b/apps/server/test/dsh-supervisor.test.ts index 03146d95d..8f93dfa62 100644 --- a/apps/server/test/dsh-supervisor.test.ts +++ b/apps/server/test/dsh-supervisor.test.ts @@ -29,7 +29,32 @@ async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { spawn: () => fake.child, logger, }); - const query = vi.fn(async () => ({ rows: [], rowCount: 0 })); + vi.mocked(logger.warn).mockClear(); + // A pool stand-in: every query takes a tick, and INSERTs hand back a row + // like Postgres would so the stream recorder's accumulation state works. + let nextId = 1; + const query = vi.fn(async (sql: string, params?: unknown[]) => { + await new Promise((r) => setTimeout(r, 2)); + if (/INSERT INTO agent_stream_events/.test(sql)) { + const id = nextId++; + return { + rows: [ + { + id, + agent_id: params?.[0], + seq: id, + kind: params?.[1], + key: params?.[2], + payload: JSON.parse(String(params?.[3])), + created_at: new Date(), + updated_at: new Date(), + }, + ], + rowCount: 1, + }; + } + return { rows: [], rowCount: 0 }; + }); const events: { type: string; message: string }[] = []; const deps = { pool: { query } as never, @@ -140,4 +165,50 @@ describe("DshSupervisor", () => { } as never); await expect(sup.start("agt_c")).rejects.toThrow(/not a dsh agent/); }); + + it("handles a burst of stream events in order, one writer per agent", async () => { + const { sup, query, deps } = await build({ + turn: async (_p, emit) => { + // Fire without awaiting: the driver sees these back to back. + const chunk = (text: string) => + emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + void chunk("a"); + void chunk("b"); + void emit({ + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "Read x", + kind: "read", + status: "completed", + }); + await chunk("c"); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + const writes = query.mock.calls.map( + ([sql, params]) => [String(sql).trim().slice(0, 6), params] as const + ); + const inserted = writes + .filter(([op]) => op === "INSERT") + .map(([, params]) => (params as unknown[])[1]); + // "a" opens the assistant row, "b" appends to it, the tool call closes + // it, "c" opens a second row: exactly three inserts, in stream order. + expect(inserted).toEqual(["assistant", "tool_call", "assistant"]); + const finalTexts = writes + .filter(([op]) => op === "UPDATE") + .map(([, params]) => JSON.parse(String((params as unknown[])[1])).text) + .filter((text) => typeof text === "string"); + expect(finalTexts.at(-1)).toBe("c"); + expect(finalTexts).toContain("ab"); + expect(deps.logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + "dsh event handling failed" + ); + await sup.stop("agt_1"); + }); }); From d849111a2bcf65f054bf0976caa3e2c33dab8ffc Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:28:40 -0700 Subject: [PATCH 013/254] test(dsh): end-to-end against a fake ACP dsh shim Co-Authored-By: Claude Fable 5.1 --- e2e/dsh-agent.spec.ts | 111 ++++++++++++++++++++++++++++++++++++++ e2e/fixtures/fake-dsh.mjs | 99 ++++++++++++++++++++++++++++++++++ playwright.config.ts | 2 + scripts/e2e-isolated.sh | 5 ++ 4 files changed, 217 insertions(+) create mode 100644 e2e/dsh-agent.spec.ts create mode 100755 e2e/fixtures/fake-dsh.mjs diff --git a/e2e/dsh-agent.spec.ts b/e2e/dsh-agent.spec.ts new file mode 100644 index 000000000..38dd2681c --- /dev/null +++ b/e2e/dsh-agent.spec.ts @@ -0,0 +1,111 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test, expect, type APIRequestContext } from "@playwright/test"; + +import { + authHeaders, + cleanupE2EAgents, + clickAgentRow, + createAgentViaAPI, + loadApp, + setEnabledAgentTypesViaAPI, +} from "./helpers"; + +// A dsh agent's setup runs through the tmux setup script (worktree, then a +// login shell in the pane) before the ACP child starts, so this spec needs +// the live runtime: E2E_AGENT_RUNTIME=tmux. The harness itself is the fake +// in e2e/fixtures/fake-dsh.mjs, selected through DISPATCH_DSH_BIN. +const live = process.env.DISPATCH_AGENT_RUNTIME === "tmux"; + +async function setChatSurface( + request: APIRequestContext, + enabled: boolean +): Promise { + const res = await request.post("/api/v1/app/settings/chat-surface", { + headers: authHeaders(), + data: { enabled }, + }); + expect(res.ok()).toBe(true); +} + +/** A throwaway git repo with one commit, so the worktree setup has a base. */ +function makeRepo(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "dsh-e2e-repo-")); + writeFileSync(path.join(dir, "README.md"), "# dsh e2e\n"); + const git = (...args: string[]) => + execFileSync("git", args, { cwd: dir, stdio: "ignore" }); + git("init", "-q", "-b", "main"); + git("-c", "user.email=e2e@dispatch", "-c", "user.name=e2e", "add", "."); + git( + "-c", + "user.email=e2e@dispatch", + "-c", + "user.name=e2e", + "commit", + "-q", + "-m", + "init" + ); + return dir; +} + +test.describe("dsh agent", () => { + test.skip(!live, "dsh setup completes through the tmux setup script"); + test.setTimeout(120_000); + + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("shows up as a type, streams into the Chat tab, and takes a chat message", async ({ + page, + request, + }) => { + await setEnabledAgentTypesViaAPI(request, ["claude", "codex", "dsh"]); + await setChatSurface(request, true); + const repo = makeRepo(); + const agent = await createAgentViaAPI(request, { + name: `e2e-dsh-${Date.now()}`, + type: "dsh", + cwd: repo, + useWorktree: true, + }); + expect(agent.status).toBe("running"); + + await loadApp(page); + await clickAgentRow(page, agent.id); + await page.getByTestId("center-tab-agent").click(); + const pane = page.getByTestId("chat-pane"); + await expect(pane).toBeVisible(); + + const input = pane.getByTestId("chat-composer-input"); + await input.fill("hello harness"); + await input.press("Enter"); + + await expect(pane.getByTestId("chat-activity")).toContainText( + "Read README.md", + { timeout: 30_000 } + ); + // The fake echoes its prompt, so the assistant post carries the text. + const assistant = pane.getByTestId("chat-assistant"); + await expect(assistant).toContainText("You said:", { timeout: 30_000 }); + await expect(assistant).toContainText("hello harness"); + + await expect + .poll( + async () => { + const res = await request.get(`/api/v1/agents/${agent.id}`, { + headers: authHeaders(), + }); + const body = (await res.json()) as { + agent: { latestEvent: { type: string } | null }; + }; + return body.agent.latestEvent?.type ?? null; + }, + { timeout: 30_000 } + ) + .toBe("idle"); + }); +}); diff --git a/e2e/fixtures/fake-dsh.mjs b/e2e/fixtures/fake-dsh.mjs new file mode 100755 index 000000000..80026394d --- /dev/null +++ b/e2e/fixtures/fake-dsh.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Fake `dsh` for E2E: speaks the Agent Client Protocol on stdio, ignores +// --profile/--patch, and scripts one turn per prompt: a tool call that +// completes, an assistant message echoing the prompt, and a usage total. +// It never calls a model and never touches the workspace. +import { createRequire } from "node:module"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire( + path.resolve(here, "../../apps/server/package.json") +); +const acp = require("@agentclientprotocol/sdk"); + +let conn; +let cwdBySession = new Map(); + +const agent = { + async initialize() { + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentInfo: { name: "fake-dsh", version: "0.0.0" }, + agentCapabilities: { + mcpCapabilities: { http: true }, + sessionCapabilities: { close: {}, resume: {} }, + }, + authMethods: [], + }; + }, + async authenticate() { + return {}; + }, + async newSession(params) { + const sessionId = `fake_${Date.now()}`; + cwdBySession.set(sessionId, params.cwd); + process.stderr.write( + `fake-dsh newSession cwd=${params.cwd} mcp=${JSON.stringify( + (params.mcpServers ?? []).map((s) => s.name) + )}\n` + ); + return { sessionId, configOptions: [] }; + }, + async resumeSession(params) { + cwdBySession.set(params.sessionId, params.cwd); + return { configOptions: [] }; + }, + async prompt(params) { + const text = params.prompt + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); + const cwd = cwdBySession.get(params.sessionId) ?? process.cwd(); + const emit = (update) => + conn.sessionUpdate({ sessionId: params.sessionId, update }); + await emit({ + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read README.md", + kind: "read", + status: "in_progress", + locations: [{ path: path.join(cwd, "README.md") }], + content: [], + }); + await emit({ + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + }); + for (const piece of ["You said: ", text]) { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: piece }, + }); + } + return { + stopReason: "end_turn", + usage: { + totalTokens: 120, + inputTokens: 100, + outputTokens: 20, + thoughtTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + }; + }, + async cancel() {}, + async closeSession() { + return {}; + }, +}; + +const stream = acp.ndJsonStream( + Writable.toWeb(process.stdout), + Readable.toWeb(process.stdin) +); +conn = new acp.AgentSideConnection(() => agent, stream); +process.stdin.on("end", () => process.exit(0)); diff --git a/playwright.config.ts b/playwright.config.ts index 717996cc7..3f3ad62a2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -29,6 +29,8 @@ const serialTests = [ "e2e/media-sidebar.spec.ts", // Flips the server-wide chat surface flag, which changes every agent route. "e2e/chat-surface.spec.ts", + // Also flips the chat surface flag, and enables the dsh agent type. + "e2e/dsh-agent.spec.ts", ]; export default defineConfig({ diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh index a32252359..02ad1bbbc 100755 --- a/scripts/e2e-isolated.sh +++ b/scripts/e2e-isolated.sh @@ -49,6 +49,11 @@ export E2E_PORT="$API_PORT" # each run only matches its own prefix; kill stale e2e-* sessions manually.) export DISPATCH_AGENT_RUNTIME="${E2E_AGENT_RUNTIME:-inert}" export DISPATCH_SESSION_PREFIX="$RUN_ID" +# dsh agents talk to a harness over ACP stdio. The suite never runs the real +# DeepSeek Harness: the fake in e2e/fixtures speaks the protocol and scripts +# one turn, and its home stays out of ~/.dispatch. +export DISPATCH_DSH_BIN="${DISPATCH_DSH_BIN:-$PWD/e2e/fixtures/fake-dsh.mjs}" +export DISPATCH_DSH_HOME="/tmp/dispatch-dsh-home-${RUN_ID}" if [ "$DISPATCH_AGENT_RUNTIME" = "tmux" ] && ! command -v tmux &>/dev/null; then echo "Error: E2E_AGENT_RUNTIME=tmux but tmux is not on PATH." >&2 From 533a97fb8edd0c00c33e640c67e03643a3db5282 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 12:30:29 -0700 Subject: [PATCH 014/254] feat(dsh): chat envelope tells stream-driven agents their replies are native A dsh agent's assistant text already lands in the Chat tab, so the envelope trailer must not send it to dispatch_chat_post for a plain reply; that would post twice. Questions with options still go through the tool. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/chat/envelope.ts | 16 ++++++++++++++-- apps/server/src/chat/service.ts | 25 ++++++++++++++++++++----- apps/server/test/chat-routes.test.ts | 1 + apps/server/test/chat-service.test.ts | 24 ++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/server/src/chat/envelope.ts b/apps/server/src/chat/envelope.ts index 425efe0b6..231a1de6d 100644 --- a/apps/server/src/chat/envelope.ts +++ b/apps/server/src/chat/envelope.ts @@ -63,10 +63,20 @@ export function escapeEnvelopeMarkers(text: string): string { * The whole body — text and attachment lines alike — passes through * `escapeEnvelopeMarkers`, so nothing embedded here can forge a block. */ +export type ChatEnvelopeOptions = { + /** + * The agent's replies reach the Chat tab on their own (a stream-driven + * harness such as dsh), so the trailer must not send it to + * dispatch_chat_post for a plain reply, or it posts twice. + */ + nativeReplies?: boolean; +}; + export function buildChatEnvelope( messageId: string, text: string, - attachmentLines: string[] = [] + attachmentLines: string[] = [], + options: ChatEnvelopeOptions = {} ): string { const body: string[] = []; if (text.trim().length > 0) body.push(text); @@ -79,7 +89,9 @@ export function buildChatEnvelope( `--- DISPATCH CHAT (id: ${messageId}) ---`, ...(body.length > 0 ? [safeBody] : []), "--- END DISPATCH CHAT ---", - `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`, + options.nativeReplies + ? `The user is reading Chat; your reply appears there as you write it. Only a question with options needs dispatch_chat_post (replyTo: "${messageId}").` + : `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`, ].join("\n"); } diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index a74360f0d..b3fa89906 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -72,7 +72,7 @@ export type ChatDeliveryAdapter = { held: (agentId: string) => boolean; }; -type ChatAgent = Pick; +type ChatAgent = Pick; export type ChatServiceDeps = { pool: Pool; @@ -316,7 +316,8 @@ export class ChatService { agentId, sessionName, message, - attachmentLines + attachmentLines, + await this.nativeReplies(agentId) ); this.publishChanged(agentId); return { message, delivered: null, held }; @@ -425,7 +426,13 @@ export class ChatService { client.release(); } - this.deliverDetached(agentId, sessionName, replyMessage, attachmentLines); + this.deliverDetached( + agentId, + sessionName, + replyMessage, + attachmentLines, + await this.nativeReplies(agentId) + ); this.publishChanged(agentId); return { question: answered, reply: replyMessage, delivered: null }; } @@ -454,17 +461,25 @@ export class ChatService { * shutdown waits (briefly) for it, and a restart sweeps whatever it could * not wait for to delivered=false. */ + /** Whether the agent's harness streams its replies into Chat itself. */ + private async nativeReplies(agentId: string): Promise { + const agent = await this.deps.getAgent(agentId); + return agent?.type === "dsh"; + } + private deliverDetached( agentId: string, sessionName: string, message: ChatMessage, - attachmentLines: string[] = [] + attachmentLines: string[] = [], + nativeReplies = false ): { held: boolean } { const delivery = this.delivery(); const envelope = buildChatEnvelope( message.id, message.text, - attachmentLines + attachmentLines, + { nativeReplies } ); const settlement = delivery .inject(agentId, sessionName, envelope) diff --git a/apps/server/test/chat-routes.test.ts b/apps/server/test/chat-routes.test.ts index 1a6666d2e..7ca30a11e 100644 --- a/apps/server/test/chat-routes.test.ts +++ b/apps/server/test/chat-routes.test.ts @@ -496,6 +496,7 @@ describe("chat routes with a deliverable terminal", () => { publishUiEvent: (event) => published.push(event), getAgent: async (id) => ({ id, + type: "claude", mediaDir: null, pins: [{ id: "pin_1", label: "PR", value: "https://gh/1" }] as never, }), diff --git a/apps/server/test/chat-service.test.ts b/apps/server/test/chat-service.test.ts index ea78a745b..9e3e0ad2b 100644 --- a/apps/server/test/chat-service.test.ts +++ b/apps/server/test/chat-service.test.ts @@ -35,7 +35,9 @@ beforeAll(async () => { pool, publishUiEvent: (event) => published.push(event), getAgent: async (id) => - id === A ? { id, mediaDir: null, pins: PINS as never } : null, + id === A + ? { id, type: "claude", mediaDir: null, pins: PINS as never } + : null, mediaRoot: "/media-root", }); }); @@ -587,6 +589,7 @@ describe("ChatService user workflows", () => { /** Resolve to release deliveries; absent = deliver immediately. */ gate?: Promise; fail?: boolean; + agentType?: "claude" | "dsh"; } = {} ) { const events: unknown[] = []; @@ -596,7 +599,12 @@ describe("ChatService user workflows", () => { publishUiEvent: (event) => events.push(event), getAgent: async (id) => id === A - ? { id, mediaDir: "/custom/media", pins: PINS as never } + ? { + id, + type: opts.agentType ?? "claude", + mediaDir: "/custom/media", + pins: PINS as never, + } : null, mediaRoot: "/media-root", delivery: { @@ -718,6 +726,18 @@ describe("ChatService user workflows", () => { ); }); + it("tells a stream-driven (dsh) agent its replies land in Chat by themselves", async () => { + const { svc, injected } = build({ agentType: "dsh" }); + const res = await svc.sendUserMessage(A, "hello harness"); + await settled(svc, res.message.id); + expect(injected[0].text).toContain("--- DISPATCH CHAT"); + expect(injected[0].text).toContain("hello harness"); + expect(injected[0].text).not.toContain("The user only sees Chat"); + expect(injected[0].text).toContain( + `Only a question with options needs dispatch_chat_post (replyTo: "${res.message.id}")` + ); + }); + it("sendUserMessage accepts blank text with an attachment and lists only the attachments", async () => { const { svc, injected } = build(); const res = await svc.sendUserMessage(A, "", [ From 3d912d61ef1ab13254b9cfb792ee769ea999a4da Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 13:17:43 -0700 Subject: [PATCH 015/254] fix(dsh): harden the harness process path from review Driver: an error listener on the child before any await, a bounded ACP handshake, a PATH pre-flight naming the missing binary, fallback to a new session when the stored one cannot resume, an expected flag on exit events so a requested stop is not reported as a crash, and a permission fallback that cancels rather than throws. Supervisor: one turn at a time per agent with held semantics for prompts that land mid-turn, a denylist child environment that keeps the login shell's SSH/proxy/token vars and adds the TLS CA and Dispatch vars, stopAll at shutdown, restoreRunning at boot, and overlay removal on stop. Recorder: per-row size bounds with a truncated flag, coalesced chunk writes, and tool paths relative to the agent's cwd. Prompt dispatch moves into AgentManager.getPromptTarget/promptDsh, launch guidance inputs share one helper, extractAppendedSystemPrompt replaces the codex-branch trick, payload types live with the store, the chat envelope lookup folds into delivery, dispatchMcpUrl takes only what it reads, and the unused StreamStore.latest is gone. dsh is opt-in (DEFAULT_ENABLED_AGENT_TYPES), documented in the runbook, with an update-migrations manifest for the release. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agent-type-settings.ts | 7 +- apps/server/src/agents/dsh/driver.ts | 248 +++++++++++++----- apps/server/src/agents/dsh/overlay.ts | 30 ++- apps/server/src/agents/dsh/persona.ts | 8 +- apps/server/src/agents/dsh/stream-recorder.ts | 192 ++++++++++---- apps/server/src/agents/dsh/stream-store.ts | 41 ++- apps/server/src/agents/dsh/supervisor.ts | 225 ++++++++++++---- apps/server/src/agents/manager.ts | 105 ++++++-- .../server/src/agents/tmux/command-builder.ts | 12 + apps/server/src/agents/tmux/mcp-url.ts | 2 +- apps/server/src/agents/types.ts | 6 + apps/server/src/chat/feed.ts | 41 +-- apps/server/src/chat/service.ts | 31 +-- apps/server/src/server.ts | 16 ++ apps/server/src/server/agent-prompts.ts | 26 +- apps/server/src/shared/agent-types.ts | 7 +- apps/server/test/agent-prompts.test.ts | 78 ++++-- apps/server/test/agent-type-settings.test.ts | 18 +- apps/server/test/dsh-driver.test.ts | 103 +++++++- apps/server/test/dsh-stream-recorder.test.ts | 81 +++++- apps/server/test/dsh-stream-store.test.ts | 8 +- apps/server/test/dsh-supervisor.test.ts | 127 ++++++++- apps/server/test/helpers/fake-acp-agent.ts | 22 +- .../src/components/app/agent-type-icon.tsx | 15 +- .../src/components/app/chat/chat-entries.tsx | 133 +--------- .../web/src/components/app/chat/chat-feed.tsx | 6 +- .../components/app/chat/stream-entries.tsx | 140 ++++++++++ apps/web/src/lib/agent-types.test.ts | 28 +- docs/10-operations-runbook.md | 23 +- packages/shared/src/agent-types.ts | 7 + packages/shared/src/chat-types.ts | 4 + packages/shared/src/index.ts | 6 +- scripts/e2e-isolated.sh | 2 +- .../0012-agent-stream-events.yaml | 45 ++++ 34 files changed, 1382 insertions(+), 461 deletions(-) create mode 100644 apps/web/src/components/app/chat/stream-entries.tsx create mode 100644 update-migrations/0012-agent-stream-events.yaml diff --git a/apps/server/src/agent-type-settings.ts b/apps/server/src/agent-type-settings.ts index d9f243fa8..84aed1a13 100644 --- a/apps/server/src/agent-type-settings.ts +++ b/apps/server/src/agent-type-settings.ts @@ -2,7 +2,7 @@ import type { Pool } from "pg"; import { getSetting, setSetting } from "./db/settings.js"; import { - AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, sanitizeEnabledAgentTypes, type AgentType, } from "./shared/agent-types.js"; @@ -10,6 +10,7 @@ import { export { AGENT_TYPES, CLI_AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, isCliAgentType, sanitizeEnabledAgentTypes, type AgentType, @@ -21,13 +22,13 @@ const ENABLED_AGENT_TYPES_KEY = "enabled_agent_types"; export async function getEnabledAgentTypes(pool: Pool): Promise { const raw = await getSetting(pool, ENABLED_AGENT_TYPES_KEY); if (!raw) { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } try { return sanitizeEnabledAgentTypes(JSON.parse(raw)); } catch { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } } diff --git a/apps/server/src/agents/dsh/driver.ts b/apps/server/src/agents/dsh/driver.ts index c4ae22ca0..a491bba9b 100644 --- a/apps/server/src/agents/dsh/driver.ts +++ b/apps/server/src/agents/dsh/driver.ts @@ -1,4 +1,6 @@ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; +import { access, constants as fsConstants } from "node:fs/promises"; +import path from "node:path"; import { Readable, Writable } from "node:stream"; import * as acp from "@agentclientprotocol/sdk"; @@ -18,7 +20,7 @@ export type DriverLaunch = { overlayPath: string; /** Dispatch's streamable HTTP MCP endpoint for this agent. */ mcp: { url: string; token: string }; - /** Resume this ACP session when set; otherwise create one. */ + /** Resume this ACP session when set; falls back to a new one if dsh lost it. */ sessionId: string | null; env: NodeJS.ProcessEnv; }; @@ -41,6 +43,8 @@ export type DriverEvent = code: number | null; signal: string | null; stderrTail: string; + /** True when Dispatch asked the child to stop; false for a crash. */ + expected: boolean; }; export type DriverListener = (event: DriverEvent) => void; @@ -63,16 +67,21 @@ export type SpawnFn = ( opts: { cwd: string; env: NodeJS.ProcessEnv } ) => ChildProcessLike; +type ExitInfo = { code: number | null; signal: string | null; error?: Error }; + type Live = { child: ChildProcessLike; conn: acp.ClientSideConnection; sessionId: string; stderrTail: string[]; - exited: Promise<{ code: number | null; signal: string | null }>; + exited: Promise; + /** Set at the top of stop(): the exit that follows is expected. */ + stopping: boolean; }; const STDERR_TAIL_LINES = 20; const TEARDOWN_STEP_MS = 1_500; +const HANDSHAKE_TIMEOUT_MS = 30_000; /** * The ACP SDK reports an agent-side exception as JSON-RPC "Internal error" @@ -94,6 +103,40 @@ export function describeRpcError(err: unknown): string { : err.message; } +/** + * Find the harness executable before spawning, so a missing binary is a + * clear message on the agent instead of a spawn error. The server resolves + * `dsh` with its own PATH (launchd/systemd), not the user's login shell, so + * the message points at the setting to fix. + */ +export async function resolveExecutable( + bin: string, + env: NodeJS.ProcessEnv +): Promise { + const executable = async (candidate: string) => { + try { + await access(candidate, fsConstants.X_OK); + return true; + } catch { + return false; + } + }; + if (bin.includes("/")) { + const absolute = path.resolve(bin); + if (await executable(absolute)) return absolute; + throw new Error(`dsh not found or not executable at ${absolute}`); + } + const searchPath = env.PATH ?? process.env.PATH ?? ""; + for (const dir of searchPath.split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, bin); + if (await executable(candidate)) return candidate; + } + throw new Error( + `dsh not found on the server's PATH (${bin}); set DISPATCH_DSH_BIN to an absolute path` + ); +} + function defaultSpawn( bin: string, args: string[], @@ -102,20 +145,39 @@ function defaultSpawn( return nodeSpawn(bin, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] }); } +function describeExit(exit: ExitInfo): string { + if (exit.error) { + const code = (exit.error as NodeJS.ErrnoException).code; + return code === "ENOENT" + ? `dsh could not be spawned (${exit.error.message})` + : exit.error.message; + } + return exit.code === null + ? `dsh exited on signal ${exit.signal}` + : `dsh exited with code ${exit.code}`; +} + export class DshDriver { private readonly live = new Map(); private readonly listeners = new Set(); private readonly spawnFn: SpawnFn; + private readonly resolveBinary: ( + bin: string, + env: NodeJS.ProcessEnv + ) => Promise; constructor( private readonly opts: { dshBin: string; dshHome: string; spawn?: SpawnFn; + /** Injectable for tests that spawn a fake; defaults to a PATH lookup. */ + resolveBinary?: (bin: string, env: NodeJS.ProcessEnv) => Promise; logger: DriverLogger; } ) { this.spawnFn = opts.spawn ?? defaultSpawn; + this.resolveBinary = opts.resolveBinary ?? resolveExecutable; } onEvent(listener: DriverListener): () => void { @@ -129,23 +191,39 @@ export class DshDriver { return this.live.has(agentId); } - async start(launch: DriverLaunch): Promise<{ sessionId: string }> { + liveAgentIds(): string[] { + return [...this.live.keys()]; + } + + async start( + launch: DriverLaunch + ): Promise<{ sessionId: string; resumed: boolean }> { if (this.live.has(launch.agentId)) { throw new Error(`dsh already running for ${launch.agentId}`); } + const env: NodeJS.ProcessEnv = { + ...launch.env, + DSH_HOME: this.opts.dshHome, + DSH_PERMISSION_MODE: "danger-full-access", + }; + const bin = await this.resolveBinary(this.opts.dshBin, env); const child = this.spawnFn( - this.opts.dshBin, + bin, ["--profile", "acp", "--patch", launch.overlayPath], - { - cwd: launch.cwd, - env: { - ...launch.env, - DSH_HOME: this.opts.dshHome, - DSH_PERMISSION_MODE: "danger-full-access", - }, - } + { cwd: launch.cwd, env } ); + // Both listeners go on before any await: a spawn failure (ENOENT, EACCES, + // missing cwd) is an `error` event with no `exit`, and an unhandled one + // would take the whole server down. const stderrTail: string[] = []; + const exited = new Promise((resolve) => { + child.on("exit", (code, signal) => + resolve({ code, signal: signal ?? null }) + ); + child.on("error", (error: Error) => + resolve({ code: null, signal: null, error }) + ); + }); child.stderr?.on("data", (chunk: Buffer) => { for (const line of chunk.toString("utf8").split("\n")) { if (!line.trim()) continue; @@ -153,13 +231,6 @@ export class DshDriver { if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift(); } }); - const exited = new Promise<{ code: number | null; signal: string | null }>( - (resolve) => { - child.on("exit", (code, signal) => - resolve({ code, signal: signal ?? null }) - ); - } - ); const client: acp.Client = { sessionUpdate: async (params) => { @@ -169,15 +240,24 @@ export class DshDriver { update: params.update, }); }, - // Permission prompts never fire under danger-full-access; if one does, - // allow it once rather than wedge the turn. + // Permission prompts never fire under danger-full-access. If one does, + // allow it when the agent offers that; otherwise end the call cleanly + // rather than pick an arbitrary option or throw inside the handler. requestPermission: async (params) => { - const allow = - params.options.find((o) => o.kind === "allow_once") ?? - params.options[0]; - return { - outcome: { outcome: "selected", optionId: allow.optionId }, - }; + const allow = params.options.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always" + ); + if (!allow) { + this.opts.logger.warn( + { + agentId: launch.agentId, + options: params.options.map((o) => o.kind), + }, + "dsh permission request had no allow option; cancelling" + ); + return { outcome: { outcome: "cancelled" } }; + } + return { outcome: { outcome: "selected", optionId: allow.optionId } }; }, }; if (!child.stdin || !child.stdout) { @@ -190,7 +270,7 @@ export class DshDriver { ); const conn = new acp.ClientSideConnection(() => client, stream); - try { + const handshake = (async () => { await conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { @@ -207,44 +287,89 @@ export class DshDriver { ], }, ]; - let sessionId: string; if (launch.sessionId) { - await conn.resumeSession({ - sessionId: launch.sessionId, - cwd: launch.cwd, - mcpServers, - }); - sessionId = launch.sessionId; - } else { - const res = await conn.newSession({ cwd: launch.cwd, mcpServers }); - sessionId = res.sessionId; - } - const entry: Live = { child, conn, sessionId, stderrTail, exited }; - this.live.set(launch.agentId, entry); - void exited.then(({ code, signal }) => { - if (this.live.get(launch.agentId) === entry) { - this.live.delete(launch.agentId); + try { + await conn.resumeSession({ + sessionId: launch.sessionId, + cwd: launch.cwd, + mcpServers, + }); + return { sessionId: launch.sessionId, resumed: true }; + } catch (err) { + // dsh no longer has the session (home cleared, store pruned, or an + // earlier start died after the id was recorded). A fresh session + // beats an agent that can never start again. + this.opts.logger.warn( + { err, agentId: launch.agentId, sessionId: launch.sessionId }, + "dsh could not resume the stored session; starting a new one" + ); } - this.emit({ - type: "exit", - agentId: launch.agentId, - code, - signal, - stderrTail: stderrTail.join("\n"), - }); - }); - this.opts.logger.info( - { agentId: launch.agentId, sessionId, resumed: !!launch.sessionId }, - "dsh session ready" - ); - return { sessionId }; - } catch (err) { + } + const res = await conn.newSession({ cwd: launch.cwd, mcpServers }); + return { sessionId: res.sessionId, resumed: false }; + })(); + + type Outcome = + | { ok: true; session: { sessionId: string; resumed: boolean } } + | { ok: false; reason: string }; + const outcome = await Promise.race([ + handshake.then( + (session) => ({ ok: true, session }), + (err) => ({ ok: false, reason: describeRpcError(err) }) + ), + exited.then((exit) => ({ + ok: false, + reason: `${describeExit(exit)} during startup`, + })), + new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: false, + reason: `dsh did not complete the ACP handshake within ${HANDSHAKE_TIMEOUT_MS / 1000}s`, + }), + HANDSHAKE_TIMEOUT_MS + ).unref?.() + ), + ]); + if (!outcome.ok) { + handshake.catch(() => {}); child.kill("SIGKILL"); const tail = stderrTail.length ? `\n${stderrTail.join("\n")}` : ""; - throw new Error(`dsh start failed: ${describeRpcError(err)}${tail}`, { - cause: err, - }); + throw new Error(`dsh start failed: ${outcome.reason}${tail}`); } + + const entry: Live = { + child, + conn, + sessionId: outcome.session.sessionId, + stderrTail, + exited, + stopping: false, + }; + this.live.set(launch.agentId, entry); + void exited.then((exit) => { + if (this.live.get(launch.agentId) === entry) { + this.live.delete(launch.agentId); + } + this.emit({ + type: "exit", + agentId: launch.agentId, + code: exit.code, + signal: exit.signal, + stderrTail: stderrTail.join("\n"), + expected: entry.stopping, + }); + }); + this.opts.logger.info( + { + agentId: launch.agentId, + sessionId: entry.sessionId, + resumed: outcome.session.resumed, + }, + "dsh session ready" + ); + return outcome.session; } /** Runs one turn; resolves when the agent settles it. */ @@ -279,6 +404,7 @@ export class DshDriver { async stop(agentId: string): Promise { const entry = this.live.get(agentId); if (!entry) return; + entry.stopping = true; try { await Promise.race([ entry.conn.closeSession({ sessionId: entry.sessionId }), diff --git a/apps/server/src/agents/dsh/overlay.ts b/apps/server/src/agents/dsh/overlay.ts index d608458ce..08ee507a1 100644 --- a/apps/server/src/agents/dsh/overlay.ts +++ b/apps/server/src/agents/dsh/overlay.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { stringify } from "yaml"; @@ -39,6 +39,8 @@ export function splitModelId(model: string): { * The per-agent `--patch` layer. Each entry replaces the config of the row * with that id in the composed acp profile: provider routes, the deployment * persona, and the default model for both new agents and ACP sessions. + * `yaml.stringify` quotes scalars, so persona text or a model id cannot + * smuggle a `!!js` tag or extra rows into the patch. */ export function buildOverlayYaml(input: OverlayInput): string { const rows: { id: string; config: Record }[] = [ @@ -56,14 +58,32 @@ export function buildOverlayYaml(input: OverlayInput): string { return stringify(rows); } -/** Writes `/.patch.yml` and returns its path. */ +function overlayPath(dir: string, agentId: string): string { + return path.join(dir, `${agentId}.patch.yml`); +} + +/** + * Writes `/.patch.yml` and returns its path. The file holds + * the persona text, which for a review launch includes the parent's brief, + * so it is private to the user and removed when the agent stops. + */ export async function writeOverlay( dir: string, agentId: string, input: OverlayInput ): Promise { - await mkdir(dir, { recursive: true }); - const file = path.join(dir, `${agentId}.patch.yml`); - await writeFile(file, buildOverlayYaml(input), "utf8"); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const file = overlayPath(dir, agentId); + await writeFile(file, buildOverlayYaml(input), { + encoding: "utf8", + mode: 0o600, + }); return file; } + +export async function removeOverlay( + dir: string, + agentId: string +): Promise { + await unlink(overlayPath(dir, agentId)).catch(() => {}); +} diff --git a/apps/server/src/agents/dsh/persona.ts b/apps/server/src/agents/dsh/persona.ts index ae94794c3..4525a03a4 100644 --- a/apps/server/src/agents/dsh/persona.ts +++ b/apps/server/src/agents/dsh/persona.ts @@ -2,7 +2,7 @@ import type { AgentRecord } from "@dispatch/shared"; import { buildLaunchGuidance, - normalizeAgentArgsForType, + extractAppendedSystemPrompt, } from "../tmux/command-builder.js"; /** @@ -34,10 +34,8 @@ export function buildDshPersona(input: { chatSurface: input.chatSurface, }); // A persona launch stores its brief as `--append-system-prompt ` - // in agentArgs. Normalising "as codex" is the branch that extracts that - // pair (the claude branch passes args through untouched). - const { appendedSystemPrompt } = normalizeAgentArgsForType( - "codex", + // in agentArgs. + const { appendedSystemPrompt } = extractAppendedSystemPrompt( agent.agentArgs ?? [] ); const sections = [guidance.trim()]; diff --git a/apps/server/src/agents/dsh/stream-recorder.ts b/apps/server/src/agents/dsh/stream-recorder.ts index a31d6fef6..7355d4a0f 100644 --- a/apps/server/src/agents/dsh/stream-recorder.ts +++ b/apps/server/src/agents/dsh/stream-recorder.ts @@ -1,40 +1,52 @@ +import path from "node:path"; + import type { DriverEvent, DriverUpdate } from "./driver.js"; -import type { StreamEventRow, StreamStore } from "./stream-store.js"; +import type { + StreamEventRow, + StreamStore, + ToolPayload, +} from "./stream-store.js"; type TextKind = "assistant" | "thought"; -type OpenText = { row: StreamEventRow; text: string }; - -/** Payload shape of a `tool_call` row; the Chat feed reads these fields. */ -export type ToolPayload = { - toolKind: string; - title: string; - status: "pending" | "in_progress" | "completed" | "failed"; - locations: { path: string; line?: number }[]; - diff: { path: string; oldText: string | null; newText: string } | null; - terminalOutput: string | null; +type OpenText = { + row: StreamEventRow; + text: string; + truncated: boolean; + /** Text as last written; a flush is a no-op when nothing changed. */ + written: string; + flushTimer: NodeJS.Timeout | null; }; +/** Model output is not trusted input: bound what one row can hold. */ +export const TEXT_MAX_BYTES = 64 * 1024; +export const TERMINAL_OUTPUT_MAX_BYTES = 32 * 1024; +/** Chunks arrive per token; rewrite the row at most this often. */ +export const FLUSH_INTERVAL_MS = 100; + function textOf(content: { type: string; text?: string } | undefined): string { return content && content.type === "text" && typeof content.text === "string" ? content.text : ""; } -function projectLocations( - locations: - | readonly { path: string; line?: number | null }[] - | null - | undefined -): ToolPayload["locations"] { - return (locations ?? []).map((l) => - l.line != null ? { path: l.path, line: l.line } : { path: l.path } - ); +/** Keep the head and the tail of over-long output; the middle is the least useful part. */ +export function boundOutput( + text: string, + maxBytes: number +): { text: string; truncated: boolean } { + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) return { text, truncated: false }; + const half = Math.floor(maxBytes / 2); + const head = bytes.subarray(0, half).toString("utf8"); + const tail = bytes.subarray(-half).toString("utf8"); + return { text: `${head}\n… [truncated] …\n${tail}`, truncated: true }; } function projectToolContent(content: readonly unknown[] | null | undefined): { diff: ToolPayload["diff"]; terminalOutput: string | null; + truncated: boolean; } { let diff: ToolPayload["diff"] = null; let terminalOutput: string | null = null; @@ -52,24 +64,45 @@ function projectToolContent(content: readonly unknown[] | null | undefined): { terminalOutput = (terminalOutput ?? "") + (c.content.text ?? ""); } } - return { diff, terminalOutput }; + let truncated = false; + if (terminalOutput !== null) { + const bounded = boundOutput(terminalOutput, TERMINAL_OUTPUT_MAX_BYTES); + terminalOutput = bounded.text; + truncated = bounded.truncated; + } + if (diff) { + const bounded = boundOutput(diff.newText, TEXT_MAX_BYTES); + if (bounded.truncated) { + diff = { ...diff, newText: bounded.text }; + truncated = true; + } + } + return { diff, terminalOutput, truncated }; } /** * Folds driver events into `agent_stream_events` rows. Assistant and * thought chunks accumulate into one open row each until something else - * interrupts them (a tool call, a settled turn, a process exit); tool calls + * interrupts them (a tool call, a settled turn, a process exit); the row is + * rewritten at most every {@link FLUSH_INTERVAL_MS} and on close. Tool calls * are keyed by toolCallId and rewritten as they settle. One instance serves - * every agent; open-row state is per agent. + * every agent; open-row state is per agent, and callers serialize events + * per agent (see DshSupervisor). */ export class StreamRecorder { private readonly open = new Map< string, Partial> >(); + private readonly cwd = new Map(); constructor(private readonly store: StreamStore) {} + /** The agent's working directory, so file paths render relative to it. */ + setCwd(agentId: string, cwd: string): void { + this.cwd.set(agentId, cwd); + } + async handle(event: DriverEvent): Promise { switch (event.type) { case "update": @@ -86,7 +119,8 @@ export class StreamRecorder { return; case "exit": { await this.closeText(event.agentId); - if (event.code === 0) return; + this.cwd.delete(event.agentId); + if (event.expected || event.code === 0) return; const how = event.code === null ? `signal ${event.signal}` : `code ${event.code}`; const detail = event.stderrTail ? `: ${event.stderrTail}` : ""; @@ -98,6 +132,35 @@ export class StreamRecorder { } } + /** Write any buffered text for the agent now (tests and shutdown). */ + async flush(agentId: string): Promise { + const state = this.open.get(agentId); + if (!state) return; + for (const kind of ["assistant", "thought"] as const) { + const current = state[kind]; + if (current) await this.write(kind, current, true); + } + } + + private projectLocations( + agentId: string, + locations: + | readonly { path: string; line?: number | null }[] + | null + | undefined + ): ToolPayload["locations"] { + const cwd = this.cwd.get(agentId); + return (locations ?? []).map((l) => { + const relative = + cwd && (l.path === cwd || l.path.startsWith(`${cwd}${path.sep}`)) + ? path.relative(cwd, l.path) || "." + : l.path; + return l.line != null + ? { path: relative, line: l.line } + : { path: relative }; + }); + } + private async handleUpdate( agentId: string, update: DriverUpdate @@ -109,14 +172,17 @@ export class StreamRecorder { return this.appendText(agentId, "thought", textOf(update.content)); case "tool_call": { await this.closeText(agentId); - const { diff, terminalOutput } = projectToolContent(update.content); + const { diff, terminalOutput, truncated } = projectToolContent( + update.content + ); const payload: ToolPayload = { toolKind: update.kind ?? "other", title: update.title, status: update.status ?? "pending", - locations: projectLocations(update.locations), + locations: this.projectLocations(agentId, update.locations), diff, terminalOutput, + ...(truncated ? { truncated: true } : {}), }; await this.store.upsertByKey( agentId, @@ -145,16 +211,19 @@ export class StreamRecorder { const projected = update.content ? projectToolContent(update.content) : null; + const truncated = + (projected?.truncated ?? false) || prev.truncated === true; const next: ToolPayload = { toolKind: update.kind ?? prev.toolKind ?? "other", title: update.title ?? prev.title ?? "", status: update.status ?? prev.status ?? "pending", locations: update.locations - ? projectLocations(update.locations) + ? this.projectLocations(agentId, update.locations) : (prev.locations ?? []), diff: projected?.diff ?? prev.diff ?? null, terminalOutput: projected?.terminalOutput ?? prev.terminalOutput ?? null, + ...(truncated ? { truncated: true } : {}), }; await this.store.updatePayload(existing.id, next); return; @@ -164,6 +233,30 @@ export class StreamRecorder { } } + private payloadFor(kind: TextKind, current: OpenText, streaming: boolean) { + const truncated = current.truncated ? { truncated: true } : {}; + return kind === "assistant" + ? { text: current.text, streaming, ...truncated } + : { text: current.text, ...truncated }; + } + + private async write( + kind: TextKind, + current: OpenText, + streaming: boolean + ): Promise { + if (current.flushTimer) { + clearTimeout(current.flushTimer); + current.flushTimer = null; + } + if (current.written === current.text && streaming) return; + current.written = current.text; + await this.store.updatePayload( + current.row.id, + this.payloadFor(kind, current, streaming) + ); + } + private async appendText( agentId: string, kind: TextKind, @@ -182,18 +275,34 @@ export class StreamRecorder { ? { text: delta, streaming: true } : { text: delta } ); - current = { row, text: delta }; - } else { - current.text += delta; - await this.store.updatePayload( - current.row.id, - kind === "assistant" - ? { text: current.text, streaming: true } - : { text: current.text } - ); + current = { + row, + text: delta, + truncated: false, + written: delta, + flushTimer: null, + }; + state[kind] = current; + this.open.set(agentId, state); + return; + } + if (current.truncated) return; + current.text += delta; + if (Buffer.byteLength(current.text, "utf8") > TEXT_MAX_BYTES) { + const bounded = boundOutput(current.text, TEXT_MAX_BYTES); + current.text = bounded.text; + current.truncated = true; + await this.write(kind, current, true); + return; + } + if (!current.flushTimer) { + const pending = current; + current.flushTimer = setTimeout(() => { + pending.flushTimer = null; + void this.write(kind, pending, true).catch(() => {}); + }, FLUSH_INTERVAL_MS); + current.flushTimer.unref?.(); } - state[kind] = current; - this.open.set(agentId, state); } private async closeText(agentId: string, only?: TextKind): Promise { @@ -203,12 +312,7 @@ export class StreamRecorder { if (only && kind !== only) continue; const current = state[kind]; if (!current) continue; - await this.store.updatePayload( - current.row.id, - kind === "assistant" - ? { text: current.text, streaming: false } - : { text: current.text } - ); + await this.write(kind, current, false); delete state[kind]; } if (!state.assistant && !state.thought) this.open.delete(agentId); diff --git a/apps/server/src/agents/dsh/stream-store.ts b/apps/server/src/agents/dsh/stream-store.ts index 1373eb821..1ae806982 100644 --- a/apps/server/src/agents/dsh/stream-store.ts +++ b/apps/server/src/agents/dsh/stream-store.ts @@ -2,6 +2,33 @@ import type { Queryable } from "../../chat/store.js"; export type StreamEventKind = "assistant" | "thought" | "tool_call" | "status"; +/** Payload shapes by row kind. The recorder writes them; the Chat feed reads them. */ +export type AssistantPayload = { + text: string; + streaming: boolean; + /** Set when the text hit the per-row size bound. */ + truncated?: boolean; +}; +export type ThoughtPayload = { text: string; truncated?: boolean }; +export type ToolPayload = { + /** Agent Client Protocol tool kind (read, edit, execute, ...) or "other". */ + toolKind: string; + title: string; + status: "pending" | "in_progress" | "completed" | "failed"; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; + /** Set when terminal output or the diff hit the per-row size bound. */ + truncated?: boolean; +}; +export type StatusPayload = { message: string }; +export type StreamPayloadByKind = { + assistant: AssistantPayload; + thought: ThoughtPayload; + tool_call: ToolPayload; + status: StatusPayload; +}; + export type StreamEventRow = { id: number; agentId: string; @@ -106,20 +133,6 @@ export class StreamStore { return this.append(agentId, kind, payload, key); } - async latest( - agentId: string, - kind: StreamEventKind - ): Promise { - const result = await this.db.query( - `SELECT * FROM agent_stream_events - WHERE agent_id = $1 AND kind = $2 - ORDER BY seq DESC - LIMIT 1`, - [agentId, kind] - ); - return result.rows[0] ? toRow(result.rows[0]) : null; - } - async updatePayload( id: number, payload: Record diff --git a/apps/server/src/agents/dsh/supervisor.ts b/apps/server/src/agents/dsh/supervisor.ts index c2fcfb573..5c04d035e 100644 --- a/apps/server/src/agents/dsh/supervisor.ts +++ b/apps/server/src/agents/dsh/supervisor.ts @@ -4,16 +4,20 @@ import type { AgentLatestEventType, AgentRecord } from "@dispatch/shared"; import { createAgentMcpToken } from "../../auth.js"; import type { AppConfig } from "../../config.js"; +import { resolveMediaDir } from "../../shared/media.js"; import { dispatchMcpUrl } from "../tmux/mcp-url.js"; import { DshDriver, type DriverEvent, type DriverLogger } from "./driver.js"; -import { writeOverlay } from "./overlay.js"; +import { removeOverlay, writeOverlay } from "./overlay.js"; import { StreamRecorder } from "./stream-recorder.js"; import { StreamStore } from "./stream-store.js"; import { UsageRecorder } from "./usage-recorder.js"; export type SupervisorDeps = { pool: Pool; - config: Pick; + config: Pick< + AppConfig, + "dshBin" | "dshHome" | "port" | "tls" | "authToken" | "mediaRoot" + >; logger: DriverLogger; /** Injectable for tests; defaults to a driver over the real `dsh` binary. */ driver?: DshDriver; @@ -27,25 +31,61 @@ export type SupervisorDeps = { publishChat: (agentId: string) => void; /** Full persona text for the overlay (see persona.ts). */ personaPromptFor: (agent: AgentRecord) => Promise; + /** dsh agents recorded as running, for {@link DshSupervisor.restoreRunning}. */ + listRunningAgentIds: () => Promise; + /** Record that an agent could not be brought back at boot. */ + markStartFailed: (id: string, message: string) => Promise; }; /** - * Environment the dsh child inherits from the server. Provider keys ride - * along when set; everything else stays out so the child sees a clean shell. + * What the dsh child must not inherit from the server process. Everything + * else passes through, the same as the tmux login shell a CLI agent gets, + * so git over SSH, gh, proxies, and locale behave the same in both. */ -const PASSTHROUGH_ENV = [ - "PATH", - "HOME", - "SHELL", - "LANG", - "LC_ALL", - "TMPDIR", - "DEEPSEEK_API_KEY", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", -]; +const ENV_DENY_EXACT = new Set([ + "DATABASE_URL", + "TEST_DATABASE_URL", + "PGPASSWORD", + "PGUSER", + "PGHOST", + "PGPORT", + "PGDATABASE", + "MEDIA_ROOT", + "TLS_CERT", + "TLS_KEY", + "TLS_CA", +]); +const ENV_DENY_PREFIX = "DISPATCH_"; + +export function buildChildEnv(input: { + agentId: string; + mediaDir: string; + config: Pick; + base?: NodeJS.ProcessEnv; +}): NodeJS.ProcessEnv { + const base = input.base ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(base)) { + if (value === undefined) continue; + if (ENV_DENY_EXACT.has(key) || key.startsWith(ENV_DENY_PREFIX)) continue; + env[key] = value; + } + // The same contract the pane launch exports (command-builder.ts), so + // plugin skills and hooks the agent's shell tools run see one shape. + env.DISPATCH_AGENT_ID = input.agentId; + env.DISPATCH_MEDIA_DIR = input.mediaDir; + env.DISPATCH_PORT = String(input.config.port); + env.DISPATCH_SCHEME = input.config.tls ? "https" : "http"; + // Under TLS the MCP URL is loopback https; the child needs the CA the pane + // launch also exports, or every Dispatch tool call fails verification. + if (input.config.tls && base.TLS_CA && !env.NODE_EXTRA_CA_CERTS) { + env.NODE_EXTRA_CA_CERTS = base.TLS_CA; + } + return env; +} const MESSAGE_MAX = 200; +const STOP_ALL_TIMEOUT_MS = 5_000; /** * Glue between the agent lifecycle and the ACP driver: starts dsh when an @@ -68,6 +108,11 @@ export class DshSupervisor { * state. Chaining each agent's events keeps order and the invariant. */ private readonly queues = new Map>(); + /** + * One turn at a time per agent. ACP allows one active prompt per session; + * a prompt that arrives mid-turn waits and runs as the next turn. + */ + private readonly turns = new Map>(); constructor(private readonly deps: SupervisorDeps) { this.driver = @@ -95,47 +140,117 @@ export class DshSupervisor { return this.driver.isRunning(agentId); } + /** A turn is running or queued for this agent. */ + isBusy(agentId: string): boolean { + return this.turns.has(agentId); + } + + private overlayDir(): string { + return path.join(this.deps.config.dshHome, "overlays"); + } + async start(agentId: string): Promise { const agent = await this.deps.getAgent(agentId); if (!agent || agent.type !== "dsh") { throw new Error(`${agentId} is not a dsh agent`); } - const overlayPath = await writeOverlay( - path.join(this.deps.config.dshHome, "overlays"), + const overlayPath = await writeOverlay(this.overlayDir(), agentId, { + model: agent.model ?? null, + persona: await this.deps.personaPromptFor(agent), + }); + const mediaDir = resolveMediaDir( agentId, - { - model: agent.model ?? null, - persona: await this.deps.personaPromptFor(agent), - } + agent.mediaDir, + this.deps.config.mediaRoot ); - const env: NodeJS.ProcessEnv = {}; - for (const key of PASSTHROUGH_ENV) { - if (process.env[key]) env[key] = process.env[key]; - } - env.DISPATCH_AGENT_ID = agentId; - const { sessionId } = await this.driver.start({ + this.streams.setCwd(agentId, agent.cwd); + const { sessionId, resumed } = await this.driver.start({ agentId, cwd: agent.cwd, overlayPath, mcp: { - url: dispatchMcpUrl(this.deps.config as AppConfig, agentId), + url: dispatchMcpUrl(this.deps.config, agentId), token: createAgentMcpToken(this.deps.config.authToken, agentId), }, sessionId: agent.cliSessionId ?? null, - env, + env: buildChildEnv({ agentId, mediaDir, config: this.deps.config }), }); this.context.set(agentId, { sessionId, model: agent.model ?? "default" }); await this.deps.setCliSessionId(agentId, sessionId); await this.deps.setLatestEvent(agentId, { type: "idle", - message: agent.cliSessionId - ? "dsh session resumed." - : "dsh session started.", + message: resumed ? "dsh session resumed." : "dsh session started.", + }); + } + + /** + * Bring back every dsh agent recorded as running after a server restart. + * The stored session id resumes; an agent that cannot come back is marked + * failed rather than left "running" with nothing behind it. + */ + async restoreRunning(): Promise<{ restored: string[]; failed: string[] }> { + const restored: string[] = []; + const failed: string[] = []; + for (const id of await this.deps.listRunningAgentIds()) { + try { + await this.start(id); + restored.push(id); + } catch (err) { + failed.push(id); + const message = (err as Error).message; + this.deps.logger.warn( + { err, agentId: id }, + "dsh agent could not be restored at boot" + ); + await this.deps + .markStartFailed(id, message.slice(0, MESSAGE_MAX)) + .catch(() => {}); + } + } + return { restored, failed }; + } + + /** + * Queue one turn. `started` resolves when the turn begins (earlier turns + * for the agent have settled); `settled` resolves when it ends and never + * rejects. + */ + enqueuePrompt( + agentId: string, + text: string + ): { started: Promise; settled: Promise } { + let markStarted: () => void = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; }); + const prior = this.turns.get(agentId) ?? Promise.resolve(); + const run: Promise = prior.then(() => + this.runTurn( + agentId, + text, + markStarted, + () => this.turns.get(agentId) === run + ) + ); + this.turns.set(agentId, run); + void run.finally(() => { + if (this.turns.get(agentId) === run) this.turns.delete(agentId); + }); + return { started, settled: run }; } - /** Runs one turn. Resolves after the turn settles; never throws. */ + /** Runs one turn after any queued before it; resolves when it settles. */ async prompt(agentId: string, text: string): Promise { + await this.enqueuePrompt(agentId, text).settled; + } + + private async runTurn( + agentId: string, + text: string, + markStarted: () => void, + isLastQueued: () => boolean + ): Promise { + markStarted(); await this.deps.setLatestEvent(agentId, { type: "working", message: "Working on the latest message.", @@ -143,17 +258,21 @@ export class DshSupervisor { try { await this.driver.prompt(agentId, text); await this.drained(agentId); - await this.deps.setLatestEvent(agentId, { - type: "idle", - message: "Turn finished.", - }); + if (isLastQueued()) { + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: "Turn finished.", + }); + } } catch (err) { const message = (err as Error).message; this.deps.logger.warn({ err, agentId }, "dsh prompt failed"); - await this.deps.setLatestEvent(agentId, { - type: "idle", - message: `Turn failed: ${message}`.slice(0, MESSAGE_MAX), - }); + if (isLastQueued()) { + await this.deps.setLatestEvent(agentId, { + type: "idle", + message: `Turn failed: ${message}`.slice(0, MESSAGE_MAX), + }); + } } } @@ -165,6 +284,17 @@ export class DshSupervisor { async stop(agentId: string): Promise { await this.driver.stop(agentId); this.context.delete(agentId); + await removeOverlay(this.overlayDir(), agentId); + } + + /** Server shutdown: stop every child through the teardown ladder, bounded. */ + async stopAll(): Promise { + const ids = this.driver.liveAgentIds(); + if (ids.length === 0) return; + await Promise.race([ + Promise.allSettled(ids.map((id) => this.stop(id))), + new Promise((resolve) => setTimeout(resolve, STOP_ALL_TIMEOUT_MS)), + ]); } /** Resolves once every event queued so far for the agent has been handled. */ @@ -178,13 +308,14 @@ export class DshSupervisor { const ctx = this.context.get(event.agentId); if (ctx) await this.usage.handle(event, ctx); this.deps.publishChat(event.agentId); - if (event.type === "exit" && event.code !== 0 && ctx) { - // ctx still set means we did not stop it ourselves. + if (event.type === "exit" && !event.expected) { this.context.delete(event.agentId); - await this.deps.setLatestEvent(event.agentId, { - type: "blocked", - message: `dsh exited (${event.code ?? event.signal ?? "unknown"}).`, - }); + if (event.code !== 0) { + await this.deps.setLatestEvent(event.agentId, { + type: "blocked", + message: `dsh exited (${event.code ?? event.signal ?? "unknown"}).`, + }); + } } } catch (err) { this.deps.logger.warn( diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index fc56fb94d..1558e80b1 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -30,6 +30,7 @@ import { harvestTokenUsage } from "./token-harvester.js"; import { errorMessage } from "../shared/lib/error-message.js"; import { buildDshPersona } from "./dsh/persona.js"; import type { DshSupervisor } from "./dsh/supervisor.js"; +import type { AgentPromptTarget } from "./types.js"; import { beginArchive as beginArchiveImpl, executeArchive as executeArchiveImpl, @@ -343,12 +344,80 @@ export class AgentManager { this.dshSupervisor = supervisor; } - getDshSupervisor(): DshSupervisor | null { - return this.dshSupervisor; + /** + * Where a prompt for this agent goes: the ACP child for a dsh agent, the + * tmux pane for a CLI agent, or nowhere in inert mode. One agent read. + */ + async getPromptTarget(id: string): Promise { + const agent = await this.getRequiredAgent(id); + if (agent.type === "dsh") { + if (!this.dshSupervisor?.isRunning(id)) { + throw new AgentError( + "dsh is not running for this agent — prompt cannot be delivered.", + 409 + ); + } + return { kind: "dsh", busy: this.dshSupervisor.isBusy(id) }; + } + const access = await this.terminalAccessFor(agent); + return access.mode === "tmux" + ? { kind: "tmux", sessionName: access.sessionName } + : { kind: "inert", message: access.message }; + } + + /** + * Queue one dsh turn. `started` resolves when it begins (after any turn + * already running), `settled` when it ends. See DshSupervisor.enqueuePrompt. + */ + promptDsh( + id: string, + text: string + ): { started: Promise; settled: Promise } { + if (!this.dshSupervisor) { + throw new AgentError("dsh supervisor is not attached.", 500); + } + return this.dshSupervisor.enqueuePrompt(id, text); + } + + /** dsh agents the last process left running; the supervisor restores them at boot. */ + async listRunningDshAgentIds(): Promise { + const result = await this.pool.query<{ id: string }>( + `SELECT id FROM agents + WHERE type = 'dsh' AND status = 'running' AND deleted_at IS NULL + ORDER BY created_at` + ); + return result.rows.map((row) => row.id); + } + + /** A dsh agent that could not be brought back at boot. */ + async markDshStartFailed(id: string, message: string): Promise { + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `dsh did not come back after restart: ${message}`.slice(0, 200), + metadata: { source: "system", phase: "start" }, + }); } /** The system-prompt persona a dsh agent launches with (see dsh/persona.ts). */ async buildDshPersonaFor(agent: AgentRecord): Promise { + const inputs = await this.launchGuidanceInputsFor(agent); + return buildDshPersona({ agent, ...inputs }); + } + + /** + * The per-launch inputs every harness's guidance is built from: the active + * personality (never for persona or assisted-update agents), the guidance + * flags, and whether to suggest a session rename. + */ + private async launchGuidanceInputsFor( + agent: Pick + ): Promise<{ + personalityPrompt: string | null; + trimmedGuidance: boolean; + chatSurface: boolean; + suggestSessionRename: boolean; + }> { const personality = agent.persona || agent.role === "assisted_update" ? null @@ -356,15 +425,14 @@ export class AgentManager { const { trimmedGuidance, chatSurface } = await readLaunchGuidanceFlags( this.pool ); - return buildDshPersona({ - agent, + return { personalityPrompt: personality?.prompt ?? null, trimmedGuidance, chatSurface, suggestSessionRename: shouldSuggestSessionRename(agent.name, agent.id, { persona: agent.persona, }), - }); + }; } /** Record the harness session id a dsh agent runs under (for resume). */ @@ -1274,13 +1342,12 @@ export class AgentManager { // bash script. We do it once here so both runtimes are happy.) await mkdir(mediaDir, { recursive: true }); - const personality = - agent.persona || agent.role === "assisted_update" - ? null - : await getActivePersonality(this.pool); - const { trimmedGuidance, chatSurface } = await readLaunchGuidanceFlags( - this.pool - ); + const { + personalityPrompt, + trimmedGuidance, + chatSurface, + suggestSessionRename, + } = await this.launchGuidanceInputsFor(agent); const agentCommand = buildAgentCommand( this.config, @@ -1293,13 +1360,11 @@ export class AgentManager { { cliSessionId: cliSessionId ?? undefined, resume: shouldResume, - suggestSessionRename: shouldSuggestSessionRename(agent.name, id, { - persona: agent.persona, - }), + suggestSessionRename, autoReview: !agent.persona && (agent.autoReview ?? false), trimmedGuidance, chatSurface, - personalityPrompt: personality?.prompt ?? null, + personalityPrompt, model: agent.model ?? undefined, } ); @@ -1354,7 +1419,13 @@ export class AgentManager { } async getTerminalAccess(id: string): Promise { - const agent = await this.getRequiredAgent(id); + return this.terminalAccessFor(await this.getRequiredAgent(id)); + } + + private async terminalAccessFor( + agent: AgentRecord + ): Promise { + const id = agent.id; if (agent.status !== "running" && agent.status !== "creating") { throw new AgentError("Agent is not running.", 409); } diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index d6c7e9f1b..e59123144 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -56,7 +56,19 @@ export function normalizeAgentArgsForType( if (type === "claude") { return { passthroughArgs: args, appendedSystemPrompt: null }; } + return extractAppendedSystemPrompt(args); +} +/** + * Split a `--append-system-prompt ` pair out of an arg list. This is + * how a persona launch carries its brief; the CLI branches that take the + * prompt through their own flag call this, and so does the dsh persona + * builder, which folds the brief into the harness's system prompt. + */ +export function extractAppendedSystemPrompt(args: string[]): { + passthroughArgs: string[]; + appendedSystemPrompt: string | null; +} { const passthroughArgs: string[] = []; let appendedSystemPrompt: string | null = null; diff --git a/apps/server/src/agents/tmux/mcp-url.ts b/apps/server/src/agents/tmux/mcp-url.ts index 760ab6b1d..f3a1d374d 100644 --- a/apps/server/src/agents/tmux/mcp-url.ts +++ b/apps/server/src/agents/tmux/mcp-url.ts @@ -6,7 +6,7 @@ import type { AppConfig } from "../../config.js"; * dedicated `/api/mcp/jobs//` route. */ export function dispatchMcpUrl( - config: AppConfig, + config: Pick, agentId: string, jobRunId?: string ): string { diff --git a/apps/server/src/agents/types.ts b/apps/server/src/agents/types.ts index f9810ff1f..dd5f7fa4f 100644 --- a/apps/server/src/agents/types.ts +++ b/apps/server/src/agents/types.ts @@ -38,6 +38,12 @@ export type AgentTerminalAccess = | { mode: "tmux"; sessionName: string } | { mode: "inert"; message: string }; +/** Where a prompt for an agent is delivered (see AgentManager.getPromptTarget). */ +export type AgentPromptTarget = + | { kind: "dsh"; busy: boolean } + | { kind: "tmux"; sessionName: string } + | { kind: "inert"; message: string }; + export type AgentLatestEventInput = { type: AgentLatestEventType; message: string; diff --git a/apps/server/src/chat/feed.ts b/apps/server/src/chat/feed.ts index 61779838f..02f6b753b 100644 --- a/apps/server/src/chat/feed.ts +++ b/apps/server/src/chat/feed.ts @@ -1,6 +1,5 @@ import type { ChatActivityEntry, - ChatActivityStatus, ChatAgentMessageEntry, ChatAssistantEntry, ChatFeedEntry, @@ -10,6 +9,10 @@ import type { ChatStatusEntry, } from "@dispatch/shared"; +import type { + AssistantPayload, + ToolPayload, +} from "../agents/dsh/stream-store.js"; import { type ChatStore, isChatMessageId, @@ -400,17 +403,6 @@ async function listReviewEntries( })); } -type StreamPayload = { - text?: string; - streaming?: boolean; - toolKind?: string; - title?: string; - status?: ChatActivityStatus; - locations?: { path: string; line?: number }[]; - diff?: { path: string; oldText: string | null; newText: string } | null; - terminalOutput?: string | null; -}; - /** * Stream rows from a protocol-driven harness (dsh over ACP): assistant text * and tool calls. Thoughts and status rows stay out of the feed. @@ -424,13 +416,22 @@ async function listStreamEntries( const params: unknown[] = [agentId]; const clause = cursorClause("assistant", "int", cursor, params); params.push(limit); - const result = await db.query<{ - id: number; - kind: "assistant" | "tool_call"; - payload: StreamPayload; - created_at: Date; - at_key: string; - }>( + const result = await db.query< + | { + id: number; + kind: "assistant"; + payload: Partial; + created_at: Date; + at_key: string; + } + | { + id: number; + kind: "tool_call"; + payload: Partial; + created_at: Date; + at_key: string; + } + >( `SELECT id, kind, payload, created_at, ${AT_KEY_SQL} AS at_key FROM agent_stream_events WHERE agent_id = $1 AND kind IN ('assistant', 'tool_call') ${clause} @@ -447,6 +448,7 @@ async function listStreamEntries( id: `stream:${row.id}`, text: row.payload.text ?? "", streaming: row.payload.streaming === true, + ...(row.payload.truncated ? { truncated: true } : {}), at, } : { @@ -458,6 +460,7 @@ async function listStreamEntries( locations: row.payload.locations ?? [], diff: row.payload.diff ?? null, terminalOutput: row.payload.terminalOutput ?? null, + ...(row.payload.truncated ? { truncated: true } : {}), at, }; return { diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index b3fa89906..e006f5744 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -312,12 +312,11 @@ export class ChatService { attachments: resolved, delivered: null, }); - const { held } = this.deliverDetached( + const { held } = await this.deliverDetached( agentId, sessionName, message, - attachmentLines, - await this.nativeReplies(agentId) + attachmentLines ); this.publishChanged(agentId); return { message, delivered: null, held }; @@ -426,12 +425,11 @@ export class ChatService { client.release(); } - this.deliverDetached( + await this.deliverDetached( agentId, sessionName, replyMessage, - attachmentLines, - await this.nativeReplies(agentId) + attachmentLines ); this.publishChanged(agentId); return { question: answered, reply: replyMessage, delivered: null }; @@ -455,31 +453,30 @@ export class ChatService { return this.deps.delivery; } - /** - * Enqueue the envelope and return at once. The detached continuation - * records true/false on the row and publishes `chat.changed`; graceful - * shutdown waits (briefly) for it, and a restart sweeps whatever it could - * not wait for to delivered=false. - */ /** Whether the agent's harness streams its replies into Chat itself. */ private async nativeReplies(agentId: string): Promise { const agent = await this.deps.getAgent(agentId); return agent?.type === "dsh"; } - private deliverDetached( + /** + * Enqueue the envelope and return at once. The detached continuation + * records true/false on the row and publishes `chat.changed`; graceful + * shutdown waits (briefly) for it, and a restart sweeps whatever it could + * not wait for to delivered=false. + */ + private async deliverDetached( agentId: string, sessionName: string, message: ChatMessage, - attachmentLines: string[] = [], - nativeReplies = false - ): { held: boolean } { + attachmentLines: string[] = [] + ): Promise<{ held: boolean }> { const delivery = this.delivery(); const envelope = buildChatEnvelope( message.id, message.text, attachmentLines, - { nativeReplies } + { nativeReplies: await this.nativeReplies(agentId) } ); const settlement = delivery .inject(agentId, sessionName, envelope) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a309d5749..6c7b72a13 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -463,6 +463,9 @@ const dshSupervisor = new DshSupervisor({ }, publishChat: (agentId) => chatService.publishChanged(agentId), personaPromptFor: (agent) => agentManager.buildDshPersonaFor(agent), + listRunningAgentIds: () => agentManager.listRunningDshAgentIds(), + markStartFailed: (agentId, message) => + agentManager.markDshStartFailed(agentId, message), }); agentManager.attachDshSupervisor(dshSupervisor); jobService.setBrainStore(brainStore); @@ -939,6 +942,12 @@ export async function initializeApp(options?: { "Marked agent messages abandoned by the previous process as not delivered" ); } + // dsh children died with the previous process while their agents stayed + // "running"; bring them back on their stored session ids. + const dshRestore = await dshSupervisor.restoreRunning(); + if (dshRestore.restored.length + dshRestore.failed.length > 0) { + app.log.info(dshRestore, "Restored dsh agents after restart"); + } await agentLifecycleRuntime.restorePendingContinuations(jobService); await jobService.reconcileActiveRuns(); await jobService.startSchedulers(); @@ -1023,6 +1032,13 @@ async function cleanupAppResources(): Promise { ); } + // Stop dsh children through their teardown ladder before the pool goes + // away (the exit rows need it); otherwise they outlive the server with + // full-access permissions and a stale MCP token. + await dshSupervisor.stopAll().catch((err: unknown) => { + app.log.warn({ err }, "Stopping dsh agents on shutdown failed"); + }); + await pool.end().catch(() => null); await app.close().catch(() => null); } diff --git a/apps/server/src/server/agent-prompts.ts b/apps/server/src/server/agent-prompts.ts index 2d359360d..bb025a744 100644 --- a/apps/server/src/server/agent-prompts.ts +++ b/apps/server/src/server/agent-prompts.ts @@ -36,29 +36,23 @@ export function createPromptInjector( prompt, opts = {} ) => { - const agent = await agentManager.getAgent(agentId); - if (agent?.type === "dsh") { - // dsh has no pane to paste into: the prompt becomes an ACP turn. The - // turn runs in the background; "delivered" means accepted, which is - // what pane injection promises for CLI agents too. - const supervisor = agentManager.getDshSupervisor(); - if (!supervisor || !supervisor.isRunning(agentId)) { - throw new Error( - "dsh is not running for this agent — prompt cannot be delivered." - ); - } - supervisor.prompt(agentId, prompt).catch((error) => { + const target = await agentManager.getPromptTarget(agentId); + if (target.kind === "dsh") { + // One turn at a time: a prompt that lands mid-turn is held until the + // running turn settles, then delivered as the next turn. "Delivered" + // means the turn started, which is what pane injection promises too. + const { started, settled } = agentManager.promptDsh(agentId, prompt); + settled.catch((error) => { appLog.warn({ err: error, agentId }, "dsh turn failed"); }); - return { held: false, delivery: Promise.resolve() }; + return { held: target.busy, delivery: started }; } - const access = await agentManager.getTerminalAccess(agentId); - if (access.mode !== "tmux") { + if (target.kind !== "tmux") { throw new Error( "Agent has no active terminal session — prompt cannot be delivered." ); } - const terminal = new TmuxTerminal(access.sessionName); + const terminal = new TmuxTerminal(target.sessionName); const delivery = coordinator.inject( agentId, () => terminal.sendCommand(prompt), diff --git a/apps/server/src/shared/agent-types.ts b/apps/server/src/shared/agent-types.ts index 2b0f58c23..be60876e4 100644 --- a/apps/server/src/shared/agent-types.ts +++ b/apps/server/src/shared/agent-types.ts @@ -11,11 +11,12 @@ import { AGENT_TYPES, CLI_AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, type AgentType, type CliAgentType, } from "@dispatch/shared"; -export { AGENT_TYPES, CLI_AGENT_TYPES }; +export { AGENT_TYPES, CLI_AGENT_TYPES, DEFAULT_ENABLED_AGENT_TYPES }; export type { AgentType, CliAgentType }; export function isCliAgentType(value: unknown): value is CliAgentType { @@ -46,11 +47,11 @@ export function isPluginAgentType(value: unknown): value is PluginAgentType { export function sanitizeEnabledAgentTypes(value: unknown): AgentType[] { if (!Array.isArray(value)) { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } const unique = value .filter(isAgentType) .filter((type, index, types) => types.indexOf(type) === index); - return unique.length > 0 ? unique : [...AGENT_TYPES]; + return unique.length > 0 ? unique : [...DEFAULT_ENABLED_AGENT_TYPES]; } diff --git a/apps/server/test/agent-prompts.test.ts b/apps/server/test/agent-prompts.test.ts index 433cfde6d..cdda41996 100644 --- a/apps/server/test/agent-prompts.test.ts +++ b/apps/server/test/agent-prompts.test.ts @@ -23,13 +23,15 @@ function build(opts: { tmux?: boolean; quietMs?: number } = {}) { maxWaitMs: 1_000, }); const agentManager = { - getAgent: vi.fn(async (id: string) => ({ id, type: "claude" })), - getDshSupervisor: vi.fn(() => null), - getTerminalAccess: vi.fn(async () => + getPromptTarget: vi.fn(async () => opts.tmux === false - ? { mode: "inert" as const, message: "No pane." } - : { mode: "tmux" as const, sessionName: "sess" } + ? { kind: "inert" as const, message: "No pane." } + : { kind: "tmux" as const, sessionName: "sess" } ), + promptDsh: vi.fn(() => ({ + started: Promise.resolve(), + settled: Promise.resolve(), + })), }; const log = { debug: vi.fn(), warn: vi.fn(), info: vi.fn(), error: vi.fn() }; const injector = createPromptInjector( @@ -129,42 +131,66 @@ describe("injectAgentPrompt (wrapper)", () => { }); describe("enqueueAgentPrompt for dsh agents", () => { - it("routes the prompt to the supervisor instead of the pane", async () => { - const prompt = vi.fn(async () => {}); + it("routes the prompt to the manager's dsh turn instead of the pane", async () => { const { enqueueAgentPrompt, agentManager } = build(); - agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); - agentManager.getDshSupervisor.mockReturnValue({ - isRunning: () => true, - prompt, - } as never); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "dsh" as const, + busy: false, + }); const { held, delivery } = await enqueueAgentPrompt("agt_d", "hello dsh"); expect(held).toBe(false); await delivery; - expect(prompt).toHaveBeenCalledWith("agt_d", "hello dsh"); + expect(agentManager.promptDsh).toHaveBeenCalledWith("agt_d", "hello dsh"); expect(sendCommand).not.toHaveBeenCalled(); }); - it("fails loudly when the dsh process is not running", async () => { + it("surfaces the manager's refusal when dsh is not running", async () => { const { enqueueAgentPrompt, agentManager } = build(); - agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); - agentManager.getDshSupervisor.mockReturnValue({ - isRunning: () => false, - prompt: vi.fn(), - } as never); + agentManager.getPromptTarget.mockRejectedValue( + new Error( + "dsh is not running for this agent — prompt cannot be delivered." + ) + ); await expect(enqueueAgentPrompt("agt_d", "x")).rejects.toThrow( /dsh is not running/ ); }); + it("reports a prompt as held while a turn is already running", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "dsh" as const, + busy: true, + }); + let start: () => void = () => {}; + agentManager.promptDsh.mockReturnValue({ + started: new Promise((r) => { + start = r; + }), + settled: Promise.resolve(), + }); + const { held, delivery } = await enqueueAgentPrompt("agt_d", "queued"); + expect(held).toBe(true); + let delivered = false; + void delivery.then(() => { + delivered = true; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(delivered).toBe(false); + start(); + await delivery; + }); + it("logs a failed turn without rejecting the enqueue", async () => { const { enqueueAgentPrompt, agentManager, log } = build(); - agentManager.getAgent.mockResolvedValue({ id: "agt_d", type: "dsh" }); - agentManager.getDshSupervisor.mockReturnValue({ - isRunning: () => true, - prompt: vi.fn(async () => { - throw new Error("turn exploded"); - }), - } as never); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "dsh" as const, + busy: false, + }); + agentManager.promptDsh.mockReturnValue({ + started: Promise.resolve(), + settled: Promise.reject(new Error("turn exploded")), + }); const { delivery } = await enqueueAgentPrompt("agt_d", "x"); await delivery; await new Promise((r) => setTimeout(r, 0)); diff --git a/apps/server/test/agent-type-settings.test.ts b/apps/server/test/agent-type-settings.test.ts index dd62b0917..fffc4aab1 100644 --- a/apps/server/test/agent-type-settings.test.ts +++ b/apps/server/test/agent-type-settings.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { - AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, sanitizeEnabledAgentTypes, } from "../src/agent-type-settings.js"; describe("sanitizeEnabledAgentTypes", () => { it("returns defaults when the value is not an array", () => { - expect(sanitizeEnabledAgentTypes(undefined)).toEqual(AGENT_TYPES); + expect(sanitizeEnabledAgentTypes(undefined)).toEqual( + DEFAULT_ENABLED_AGENT_TYPES + ); }); it("filters unknown values and removes duplicates", () => { @@ -17,6 +19,16 @@ describe("sanitizeEnabledAgentTypes", () => { }); it("falls back to defaults when the array has no valid types", () => { - expect(sanitizeEnabledAgentTypes(["unknown"])).toEqual(AGENT_TYPES); + expect(sanitizeEnabledAgentTypes(["unknown"])).toEqual( + DEFAULT_ENABLED_AGENT_TYPES + ); + }); + + it("keeps dsh opt-in but accepts it when chosen", () => { + expect(DEFAULT_ENABLED_AGENT_TYPES).not.toContain("dsh"); + expect(sanitizeEnabledAgentTypes(["dsh", "claude"])).toEqual([ + "dsh", + "claude", + ]); }); }); diff --git a/apps/server/test/dsh-driver.test.ts b/apps/server/test/dsh-driver.test.ts index 97b72fc98..cc190bff2 100644 --- a/apps/server/test/dsh-driver.test.ts +++ b/apps/server/test/dsh-driver.test.ts @@ -10,6 +10,9 @@ const logger = { debug: vi.fn(), }; +/** The fake is spawned in-process, so skip the PATH lookup. */ +const resolveBinary = async (bin: string) => bin; + function launch(agentId = "agt_1") { return { agentId, @@ -29,6 +32,7 @@ describe("DshDriver", () => { dshBin: "/bin/dsh", dshHome: "/home/dsh", spawn, + resolveBinary, logger, }); const { sessionId } = await driver.start(launch()); @@ -73,6 +77,7 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => fake.child, + resolveBinary, logger, }); const events: DriverEvent[] = []; @@ -95,6 +100,7 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => fake.child, + resolveBinary, logger, }); const { sessionId } = await driver.start({ @@ -116,6 +122,7 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => fake.child, + resolveBinary, logger, }); const events: DriverEvent[] = []; @@ -124,7 +131,11 @@ describe("DshDriver", () => { await driver.stop("agt_1"); expect(fake.seen.closes).toBe(1); expect(driver.isRunning("agt_1")).toBe(false); - expect(events.at(-1)).toMatchObject({ type: "exit", agentId: "agt_1" }); + expect(events.at(-1)).toMatchObject({ + type: "exit", + agentId: "agt_1", + expected: true, + }); }); it("refuses to start twice for one agent", async () => { @@ -133,6 +144,7 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => fake.child, + resolveBinary, logger, }); await driver.start(launch()); @@ -150,6 +162,7 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => fake.child, + resolveBinary, logger, }); const events: DriverEvent[] = []; @@ -169,8 +182,96 @@ describe("DshDriver", () => { dshBin: "dsh", dshHome: "/h", spawn: () => createFakeAcpAgent().child, + resolveBinary, logger, }); await expect(driver.prompt("agt_nope", "x")).rejects.toThrow(/not running/); }); + + it("fails the start, not the process, when the binary cannot be spawned", async () => { + const driver = new DshDriver({ + dshBin: "definitely-not-a-real-binary-dsh", + dshHome: "/h", + resolveBinary, + logger, + }); + await expect(driver.start(launch())).rejects.toThrow( + /dsh start failed: dsh could not be spawned/ + ); + expect(driver.isRunning("agt_1")).toBe(false); + }); + + it("names the missing binary before spawning", async () => { + const driver = new DshDriver({ + dshBin: "definitely-not-a-real-binary-dsh", + dshHome: "/h", + logger, + }); + await expect(driver.start(launch())).rejects.toThrow( + /dsh not found on the server's PATH/ + ); + }); + + it("falls back to a new session when the stored one cannot be resumed", async () => { + const fake = createFakeAcpAgent({ resumeFails: true }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + resolveBinary, + logger, + }); + const result = await driver.start({ ...launch(), sessionId: "sess_gone" }); + expect(result).toEqual({ sessionId: "sess_1", resumed: false }); + expect(fake.seen.resumeSession).toHaveLength(1); + expect(fake.seen.newSession).toHaveLength(1); + await driver.stop("agt_1"); + }); + + it("reports an unexpected child death as a crash", async () => { + const fake = createFakeAcpAgent(); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + fake.child.kill("SIGKILL"); + await new Promise((r) => setTimeout(r, 0)); + expect(events.at(-1)).toMatchObject({ type: "exit", expected: false }); + expect(driver.isRunning("agt_1")).toBe(false); + }); + + it("cancels a permission request that offers no allow option", async () => { + const fake = createFakeAcpAgent({ + turn: async (_p, _emit, ask) => { + const answer = await ask({ + options: [{ optionId: "no", name: "Reject", kind: "reject_once" }], + }); + return answer.outcome.outcome === "cancelled" + ? "cancelled" + : "end_turn"; + }, + }); + const driver = new DshDriver({ + dshBin: "dsh", + dshHome: "/h", + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.prompt("agt_1", "x"); + expect(events.at(-1)).toMatchObject({ + state: "settled", + stopReason: "cancelled", + }); + await driver.stop("agt_1"); + }); }); diff --git a/apps/server/test/dsh-stream-recorder.test.ts b/apps/server/test/dsh-stream-recorder.test.ts index f95d4d0ad..d4eb7b4d8 100644 --- a/apps/server/test/dsh-stream-recorder.test.ts +++ b/apps/server/test/dsh-stream-recorder.test.ts @@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { Pool } from "pg"; import type { DriverEvent } from "../src/agents/dsh/driver.js"; -import { StreamRecorder } from "../src/agents/dsh/stream-recorder.js"; +import { + boundOutput, + StreamRecorder, + TEXT_MAX_BYTES, +} from "../src/agents/dsh/stream-recorder.js"; import { StreamStore } from "../src/agents/dsh/stream-store.js"; import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; @@ -52,6 +56,7 @@ describe("StreamRecorder", () => { await rec.handle({ type: "turn", agentId: A, state: "started" }); await rec.handle(chunk("Hel")); await rec.handle(chunk("lo")); + await rec.flush(A); const open = await store.list(A, 10); expect(open[0].payload).toEqual({ text: "Hello", streaming: true }); await rec.handle({ @@ -92,6 +97,7 @@ describe("StreamRecorder", () => { }, }); await rec.handle(chunk("two")); + await rec.flush(A); const rows = (await store.list(A, 10)).reverse(); expect(rows.map((r) => r.kind)).toEqual([ "assistant", @@ -170,6 +176,7 @@ describe("StreamRecorder", () => { code: 1, signal: null, stderrTail: "boom", + expected: false, }); await rec.handle({ type: "exit", @@ -177,6 +184,16 @@ describe("StreamRecorder", () => { code: 0, signal: null, stderrTail: "", + expected: false, + }); + // A stop Dispatch asked for is not a crash, whatever signal it took. + await rec.handle({ + type: "exit", + agentId: A, + code: null, + signal: "SIGTERM", + stderrTail: "", + expected: true, }); const rows = (await store.list(A, 10)).reverse(); expect(rows.map((r) => r.payload.message)).toEqual([ @@ -184,4 +201,66 @@ describe("StreamRecorder", () => { "dsh exited with code 1: boom", ]); }); + + it("renders tool locations relative to the agent's cwd", async () => { + const rec = new StreamRecorder(store); + rec.setCwd(A, "/w/repo"); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "r1", + title: "Read", + kind: "read", + status: "completed", + locations: [ + { path: "/w/repo/src/index.ts", line: 3 }, + { path: "/etc/hosts" }, + ], + }, + }); + const rows = await store.list(A, 1); + expect(rows[0].payload.locations).toEqual([ + { path: "src/index.ts", line: 3 }, + { path: "/etc/hosts" }, + ]); + }); + + it("bounds an assistant message and marks it truncated", async () => { + const rec = new StreamRecorder(store); + const big = "x".repeat(TEXT_MAX_BYTES + 10); + await rec.handle(chunk("start")); + await rec.handle(chunk(big)); + await rec.handle(chunk("ignored after the cap")); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = await store.list(A, 1); + const payload = rows[0].payload as { + text: string; + truncated?: boolean; + streaming: boolean; + }; + expect(payload.truncated).toBe(true); + expect(payload.streaming).toBe(false); + expect(Buffer.byteLength(payload.text, "utf8")).toBeLessThanOrEqual( + TEXT_MAX_BYTES + 32 + ); + expect(payload.text).toContain("[truncated]"); + }); + + it("bounds terminal output head and tail", () => { + const out = boundOutput("a".repeat(100) + "b".repeat(100), 50); + expect(out.truncated).toBe(true); + expect(out.text.startsWith("a".repeat(25))).toBe(true); + expect(out.text.endsWith("b".repeat(25))).toBe(true); + expect(boundOutput("short", 50)).toEqual({ + text: "short", + truncated: false, + }); + }); }); diff --git a/apps/server/test/dsh-stream-store.test.ts b/apps/server/test/dsh-stream-store.test.ts index cb37caf22..282cbddc4 100644 --- a/apps/server/test/dsh-stream-store.test.ts +++ b/apps/server/test/dsh-stream-store.test.ts @@ -53,12 +53,12 @@ describe("StreamStore", () => { expect(await store.getByKey(A, "tool_call", "missing")).toBeNull(); }); - it("returns the latest row of a kind and updates a payload in place", async () => { + it("updates a payload in place", async () => { const row = await store.append(A, "assistant", { text: "a" }); await store.updatePayload(row.id, { text: "ab" }); - const latest = await store.latest(A, "assistant"); - expect(latest?.id).toBe(row.id); - expect(latest?.payload).toEqual({ text: "ab" }); + const rows = await store.list(A, 1); + expect(rows[0].id).toBe(row.id); + expect(rows[0].payload).toEqual({ text: "ab" }); }); it("lists newest first, bounded by limit", async () => { diff --git a/apps/server/test/dsh-supervisor.test.ts b/apps/server/test/dsh-supervisor.test.ts index 8f93dfa62..ba7b5144c 100644 --- a/apps/server/test/dsh-supervisor.test.ts +++ b/apps/server/test/dsh-supervisor.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DshDriver } from "../src/agents/dsh/driver.js"; -import { DshSupervisor } from "../src/agents/dsh/supervisor.js"; +import { buildChildEnv, DshSupervisor } from "../src/agents/dsh/supervisor.js"; import { createFakeAcpAgent, type FakeTurn } from "./helpers/fake-acp-agent.js"; const logger = { @@ -27,6 +27,7 @@ async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { dshBin: "dsh", dshHome: home, spawn: () => fake.child, + resolveBinary: async (bin) => bin, logger, }); vi.mocked(logger.warn).mockClear(); @@ -64,6 +65,7 @@ async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { port: 1, tls: null, authToken: "secret", + mediaRoot: path.join(home, "media"), }, logger, driver, @@ -71,6 +73,7 @@ async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { id, type: "dsh", cwd: "/tmp/w", + mediaDir: null, model: "openai/gpt-5.2", cliSessionId: opts.cliSessionId ?? null, })) as never, @@ -82,6 +85,8 @@ async function build(opts: { turn?: FakeTurn; cliSessionId?: string } = {}) { ), publishChat: vi.fn(), personaPromptFor: vi.fn(async () => "PERSONA TEXT"), + listRunningAgentIds: vi.fn(async () => [] as string[]), + markStartFailed: vi.fn(async () => {}), }; const sup = new DshSupervisor(deps); return { fake, deps, events, sup, query }; @@ -110,6 +115,9 @@ describe("DshSupervisor", () => { }); expect(sup.isRunning("agt_1")).toBe(true); await sup.stop("agt_1"); + await expect( + readFile(path.join(home, "overlays", "agt_1.patch.yml"), "utf8") + ).rejects.toThrow(); }); it("resumes a stored session id", async () => { @@ -211,4 +219,121 @@ describe("DshSupervisor", () => { ); await sup.stop("agt_1"); }); + + it("runs overlapping prompts one at a time, in order, and reports idle once", async () => { + const { sup, fake, events } = await build({ + turn: async (p, emit) => { + await new Promise((r) => setTimeout(r, 15)); + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `echo ${p}` }, + }); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const second = sup.enqueuePrompt("agt_1", "two"); + expect(sup.isBusy("agt_1")).toBe(true); + await first.started; + let secondStarted = false; + void second.started.then(() => { + secondStarted = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(secondStarted).toBe(false); + await first.settled; + await second.settled; + expect(fake.seen.prompts).toEqual(["one", "two"]); + expect(events.map((e) => e.type)).toEqual([ + "idle", + "working", + "working", + "idle", + ]); + expect(sup.isBusy("agt_1")).toBe(false); + await sup.stop("agt_1"); + }); + + it("restores running agents at boot and marks the ones that fail", async () => { + const { sup, deps, fake } = await build(); + deps.listRunningAgentIds.mockResolvedValue(["agt_1", "agt_2"]); + deps.getAgent.mockImplementation(async (id: string) => + id === "agt_2" + ? { + id, + type: "claude", + cwd: "/tmp", + mediaDir: null, + model: null, + cliSessionId: null, + } + : { + id, + type: "dsh", + cwd: "/tmp/w", + mediaDir: null, + model: null, + cliSessionId: null, + } + ); + const result = await sup.restoreRunning(); + expect(result).toEqual({ restored: ["agt_1"], failed: ["agt_2"] }); + expect(deps.markStartFailed).toHaveBeenCalledWith( + "agt_2", + expect.stringContaining("not a dsh agent") + ); + expect(fake.seen.newSession).toHaveLength(1); + await sup.stopAll(); + expect(sup.isRunning("agt_1")).toBe(false); + }); +}); + +describe("buildChildEnv", () => { + const base = { + PATH: "/usr/bin", + HOME: "/home/u", + SSH_AUTH_SOCK: "/tmp/agent.sock", + HTTPS_PROXY: "http://proxy:3128", + OPENAI_API_KEY: "sk-test", + DATABASE_URL: "postgres://secret", + PGPASSWORD: "hunter2", + DISPATCH_SESSION_PREFIX: "dispatch", + TLS_CA: "/etc/ca.pem", + }; + + it("passes the login-shell environment through and drops Dispatch internals", () => { + const env = buildChildEnv({ + agentId: "agt_1", + mediaDir: "/media/agt_1", + config: { port: 6767, tls: null }, + base, + }); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/agent.sock"); + expect(env.HTTPS_PROXY).toBe("http://proxy:3128"); + expect(env.OPENAI_API_KEY).toBe("sk-test"); + expect(env.DATABASE_URL).toBeUndefined(); + expect(env.PGPASSWORD).toBeUndefined(); + expect(env.DISPATCH_SESSION_PREFIX).toBeUndefined(); + expect(env.DISPATCH_AGENT_ID).toBe("agt_1"); + expect(env.DISPATCH_MEDIA_DIR).toBe("/media/agt_1"); + expect(env.DISPATCH_PORT).toBe("6767"); + expect(env.DISPATCH_SCHEME).toBe("http"); + expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + it("exports the TLS CA for the loopback https MCP URL", () => { + const env = buildChildEnv({ + agentId: "agt_1", + mediaDir: "/m", + config: { + port: 6767, + tls: { cert: Buffer.from(""), key: Buffer.from("") }, + }, + base, + }); + expect(env.DISPATCH_SCHEME).toBe("https"); + expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ca.pem"); + expect(env.TLS_CA).toBeUndefined(); + }); }); diff --git a/apps/server/test/helpers/fake-acp-agent.ts b/apps/server/test/helpers/fake-acp-agent.ts index 3142d920e..9d8b83a9f 100644 --- a/apps/server/test/helpers/fake-acp-agent.ts +++ b/apps/server/test/helpers/fake-acp-agent.ts @@ -4,7 +4,10 @@ import * as acp from "@agentclientprotocol/sdk"; export type FakeTurn = ( prompt: string, - emit: (update: acp.SessionUpdate) => Promise + emit: (update: acp.SessionUpdate) => Promise, + ask: ( + request: Pick + ) => Promise ) => Promise; /** @@ -12,7 +15,9 @@ export type FakeTurn = ( * injected `spawn` returns `child`; the fake agent speaks on the other ends * of the same pipes, so no real process is involved. */ -export function createFakeAcpAgent(opts: { turn?: FakeTurn } = {}) { +export function createFakeAcpAgent( + opts: { turn?: FakeTurn; resumeFails?: boolean } = {} +) { const toAgent = new PassThrough(); // driver stdin -> agent input const fromAgent = new PassThrough(); // agent output -> driver stdout const stderr = new PassThrough(); @@ -70,7 +75,8 @@ export function createFakeAcpAgent(opts: { turn?: FakeTurn } = {}) { }, async resumeSession(params) { seen.resumeSession.push(params); - return { sessionId: params.sessionId, configOptions: [] }; + if (opts.resumeFails) throw new Error("unknown session"); + return { configOptions: [] }; }, async prompt(params) { const text = params.prompt @@ -79,7 +85,15 @@ export function createFakeAcpAgent(opts: { turn?: FakeTurn } = {}) { seen.prompts.push(text); const emit = (update: acp.SessionUpdate) => connection.sessionUpdate({ sessionId: params.sessionId, update }); - const stopReason = opts.turn ? await opts.turn(text, emit) : "end_turn"; + const ask = (request: Pick) => + connection.requestPermission({ + sessionId: params.sessionId, + toolCall: { toolCallId: "perm_1", title: "permission" }, + options: request.options, + }); + const stopReason = opts.turn + ? await opts.turn(text, emit, ask) + : "end_turn"; return { stopReason }; }, async cancel() { diff --git a/apps/web/src/components/app/agent-type-icon.tsx b/apps/web/src/components/app/agent-type-icon.tsx index db2356e83..4949e0195 100644 --- a/apps/web/src/components/app/agent-type-icon.tsx +++ b/apps/web/src/components/app/agent-type-icon.tsx @@ -1,6 +1,7 @@ import { Bot, Terminal as TerminalIcon } from "lucide-react"; import { siClaude, siCursor } from "simple-icons"; +import { AGENT_TYPE_LABELS } from "@/lib/agent-types"; import { cn } from "@/lib/utils"; type AgentEventType = "working" | "blocked" | "waiting_user" | "done" | "idle"; @@ -57,19 +58,7 @@ export function AgentTypeIcon({ }: AgentTypeIconProps): JSX.Element { const normalizedType = normalizeAgentType(type); const label = - normalizedType === "claude" - ? "Claude" - : normalizedType === "opencode" - ? "OpenCode" - : normalizedType === "cursor" - ? "Cursor" - : normalizedType === "terminal" - ? "Terminal" - : normalizedType === "dsh" - ? "DSH" - : normalizedType === "codex" - ? "Codex" - : "Agent"; + normalizedType === "unknown" ? "Agent" : AGENT_TYPE_LABELS[normalizedType]; const statusClass = eventType ? eventColorClass[eventType] : ""; const baseClass = statusClass ? "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-300" diff --git a/apps/web/src/components/app/chat/chat-entries.tsx b/apps/web/src/components/app/chat/chat-entries.tsx index 1f02c65e1..07d7154e5 100644 --- a/apps/web/src/components/app/chat/chat-entries.tsx +++ b/apps/web/src/components/app/chat/chat-entries.tsx @@ -1,8 +1,6 @@ -import { memo, type ReactNode, useState } from "react"; +import { memo, type ReactNode } from "react"; import type { - ChatActivityEntry, ChatAgentMessageEntry, - ChatAssistantEntry, ChatAttachment, ChatMediaEntry, ChatMessage, @@ -154,7 +152,7 @@ function userAuthor(): PostAuthor { return { key: "user", name: "You", kind: "user" }; } -function agentAuthor(ctx: FeedContext, fallback = ""): PostAuthor { +export function agentAuthor(ctx: FeedContext, fallback = ""): PostAuthor { return { key: "agent", name: ctx.agentName ?? fallback, @@ -1180,130 +1178,3 @@ export function ReviewEntryView({ ); } - -// ── Stream-driven harness entries (dsh over ACP) ───────────────────────── - -/** Assistant text from the harness stream: the agent's own post. */ -export function AssistantEntryView({ - entry, - grouped, - rule = false, - ctx, -}: { - entry: ChatAssistantEntry; - grouped: boolean; - rule?: boolean; - ctx: FeedContext; -}): JSX.Element { - return ( - -
- {entry.text} - {entry.streaming ? ( - - ) : null} -
-
- ); -} - -const ACTIVITY_STATUS_CLASS: Record = { - pending: "bg-muted-foreground/40", - in_progress: "bg-status-working", - completed: "bg-status-done", - failed: "bg-status-blocked", -}; - -/** A line-by-line diff for an activity card; enough for a prototype view. */ -export function renderUnifiedDiff(oldText: string, newText: string): string { - const a = oldText.split("\n"); - const b = newText.split("\n"); - const out: string[] = []; - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - if (a[i] === b[i]) { - out.push(` ${a[i] ?? ""}`); - continue; - } - if (i < a.length) out.push(`- ${a[i]}`); - if (i < b.length) out.push(`+ ${b[i]}`); - } - return out.join("\n"); -} - -/** - * One tool call from the harness stream: a compact row under the agent's - * posts, expandable when it carries a diff or terminal output. - */ -export function ActivityEntryView({ - entry, - grouped, - rule = false, -}: { - entry: ChatActivityEntry; - grouped: boolean; - rule?: boolean; - ctx: FeedContext; -}): JSX.Element { - const [open, setOpen] = useState(false); - const expandable = entry.diff !== null || entry.terminalOutput !== null; - const location = entry.locations[0]?.path ?? null; - return ( -
- - {open && entry.diff ? ( -
-          {renderUnifiedDiff(entry.diff.oldText ?? "", entry.diff.newText)}
-        
- ) : null} - {open && entry.terminalOutput ? ( -
-          {entry.terminalOutput}
-        
- ) : null} -
- ); -} diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index 7eca1dde7..a83e686fa 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -16,11 +16,13 @@ import { dayLabel, MediaEntryView, reviewAuthor, - ActivityEntryView, - AssistantEntryView, ReviewEntryView, StatusLine, } from "@/components/app/chat/chat-entries"; +import { + ActivityEntryView, + AssistantEntryView, +} from "@/components/app/chat/stream-entries"; /** * A feed entry ready to render: status lines may stand in for a run of diff --git a/apps/web/src/components/app/chat/stream-entries.tsx b/apps/web/src/components/app/chat/stream-entries.tsx new file mode 100644 index 000000000..01b59a970 --- /dev/null +++ b/apps/web/src/components/app/chat/stream-entries.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import type { ChatActivityEntry, ChatAssistantEntry } from "@dispatch/shared"; + +import { + agentAuthor, + type FeedContext, + Post, + POST_BODY_MEASURE, + SIDE_POST_INDENT, +} from "@/components/app/chat/chat-entries"; +import { Markdown } from "@/components/ui/markdown"; +import { cn } from "@/lib/utils"; + +// Entries from a stream-driven harness (dsh over ACP): the agent's own text +// and its tool calls, rendered from agent_stream_events rows. + +/** Assistant text from the harness stream: the agent's own post. */ +export function AssistantEntryView({ + entry, + grouped, + rule = false, + ctx, +}: { + entry: ChatAssistantEntry; + grouped: boolean; + rule?: boolean; + ctx: FeedContext; +}): JSX.Element { + return ( + +
+ {entry.text} + {entry.streaming ? ( + + ) : null} +
+
+ ); +} + +const ACTIVITY_STATUS_CLASS: Record = { + pending: "bg-muted-foreground/40", + in_progress: "bg-status-working", + completed: "bg-status-done", + failed: "bg-status-blocked", +}; + +/** A line-by-line diff for an activity card; enough for a prototype view. */ +export function renderUnifiedDiff(oldText: string, newText: string): string { + const a = oldText.split("\n"); + const b = newText.split("\n"); + const out: string[] = []; + const max = Math.max(a.length, b.length); + for (let i = 0; i < max; i += 1) { + if (a[i] === b[i]) { + out.push(` ${a[i] ?? ""}`); + continue; + } + if (i < a.length) out.push(`- ${a[i]}`); + if (i < b.length) out.push(`+ ${b[i]}`); + } + return out.join("\n"); +} + +/** + * One tool call from the harness stream: a compact row under the agent's + * posts, expandable when it carries a diff or terminal output. + */ +export function ActivityEntryView({ + entry, + grouped, + rule = false, +}: { + entry: ChatActivityEntry; + grouped: boolean; + rule?: boolean; + ctx: FeedContext; +}): JSX.Element { + const [open, setOpen] = useState(false); + const expandable = entry.diff !== null || entry.terminalOutput !== null; + const location = entry.locations[0]?.path ?? null; + return ( +
+ + {open && entry.diff ? ( +
+          {renderUnifiedDiff(entry.diff.oldText ?? "", entry.diff.newText)}
+        
+ ) : null} + {open && entry.terminalOutput ? ( +
+          {entry.terminalOutput}
+        
+ ) : null} +
+ ); +} diff --git a/apps/web/src/lib/agent-types.test.ts b/apps/web/src/lib/agent-types.test.ts index a92c5a8a0..d21a5f4ee 100644 --- a/apps/web/src/lib/agent-types.test.ts +++ b/apps/web/src/lib/agent-types.test.ts @@ -81,11 +81,15 @@ describe("sortAgentTypes", () => { }); describe("sanitizeEnabledAgentTypes", () => { - it("returns all types for non-array input", () => { - expect(sanitizeEnabledAgentTypes(null)).toEqual([...AGENT_TYPES]); - expect(sanitizeEnabledAgentTypes(undefined)).toEqual([...AGENT_TYPES]); - expect(sanitizeEnabledAgentTypes("claude")).toEqual([...AGENT_TYPES]); - expect(sanitizeEnabledAgentTypes(42)).toEqual([...AGENT_TYPES]); + // dsh is opt-in: it needs the harness binary and a provider key on the + // server, so it stays out of the fallback list. + const defaults = AGENT_TYPES.filter((type) => type !== "dsh"); + + it("returns the default types for non-array input", () => { + expect(sanitizeEnabledAgentTypes(null)).toEqual(defaults); + expect(sanitizeEnabledAgentTypes(undefined)).toEqual(defaults); + expect(sanitizeEnabledAgentTypes("claude")).toEqual(defaults); + expect(sanitizeEnabledAgentTypes(42)).toEqual(defaults); }); it("filters valid agent types from mixed input", () => { @@ -101,14 +105,16 @@ describe("sanitizeEnabledAgentTypes", () => { ).toEqual(["claude", "codex"]); }); - it("returns all types when array is empty", () => { - expect(sanitizeEnabledAgentTypes([])).toEqual([...AGENT_TYPES]); + it("returns the default types when array is empty", () => { + expect(sanitizeEnabledAgentTypes([])).toEqual(defaults); }); - it("returns all types when array has only invalid entries", () => { - expect(sanitizeEnabledAgentTypes(["vim", 123, null])).toEqual([ - ...AGENT_TYPES, - ]); + it("returns the default types when array has only invalid entries", () => { + expect(sanitizeEnabledAgentTypes(["vim", 123, null])).toEqual(defaults); + }); + + it("keeps dsh when it was chosen explicitly", () => { + expect(sanitizeEnabledAgentTypes(["dsh"])).toEqual(["dsh"]); }); it("filters out non-string entries", () => { diff --git a/docs/10-operations-runbook.md b/docs/10-operations-runbook.md index 3a02311ef..cf941ac2f 100644 --- a/docs/10-operations-runbook.md +++ b/docs/10-operations-runbook.md @@ -204,16 +204,19 @@ PRs must pass CI before merge. Server configuration lives in `~/.dispatch/server/.env`. Key variables: -| Variable | Default | Description | -| ------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| `DISPATCH_HOST` | `127.0.0.1` | Interface to bind the API server to. Set `0.0.0.0` only when the machine must accept remote connections. | -| `DISPATCH_PORT` | `6767` | HTTP port the server listens on | -| `DATABASE_URL` | `postgres://dispatch:dispatch@127.0.0.1:5432/dispatch` | Postgres connection string | -| `MEDIA_ROOT` | `$HOME/.dispatch/media` | File upload storage path. A leading `~` is expanded, but prefer an absolute path. | -| `DISPATCH_AGENT_RUNTIME` | `tmux` | Agent runtime mode (`tmux` or `inert` for dev/test) | -| `DISPATCH_COPY_DISPLAY` | — | Virtual X display for clipboard image paste on Linux (e.g. `:99`) | -| `TLS_CERT` | — | Path to TLS certificate file (enables HTTPS when both cert and key are set) | -| `TLS_KEY` | — | Path to TLS private key file | +| Variable | Default | Description | +| --------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DISPATCH_HOST` | `127.0.0.1` | Interface to bind the API server to. Set `0.0.0.0` only when the machine must accept remote connections. | +| `DISPATCH_PORT` | `6767` | HTTP port the server listens on | +| `DATABASE_URL` | `postgres://dispatch:dispatch@127.0.0.1:5432/dispatch` | Postgres connection string | +| `MEDIA_ROOT` | `$HOME/.dispatch/media` | File upload storage path. A leading `~` is expanded, but prefer an absolute path. | +| `DISPATCH_AGENT_RUNTIME` | `tmux` | Agent runtime mode (`tmux` or `inert` for dev/test) | +| `DISPATCH_COPY_DISPLAY` | — | Virtual X display for clipboard image paste on Linux (e.g. `:99`) | +| `TLS_CERT` | — | Path to TLS certificate file (enables HTTPS when both cert and key are set) | +| `TLS_KEY` | — | Path to TLS private key file | +| `DISPATCH_DSH_BIN` | `dsh` | DeepSeek Harness launcher for `dsh` agents. Use an absolute path: the service resolves it with its own PATH, not your login shell's, so an npm/nvm install is invisible unless the path is spelled out. | +| `DISPATCH_DSH_HOME` | `$HOME/.dispatch/dsh` | `DSH_HOME` for harness sessions Dispatch launches; kept apart from a user's own `~/.dsh`. | +| `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | — | Provider keys `dsh` agents use. Put them in `~/.dispatch/server/.env`: the harness child inherits the service's environment, not your shell exports. | Changes to `.env` require a service restart to take effect. diff --git a/packages/shared/src/agent-types.ts b/packages/shared/src/agent-types.ts index 39c83d4b6..9537734e5 100644 --- a/packages/shared/src/agent-types.ts +++ b/packages/shared/src/agent-types.ts @@ -28,3 +28,10 @@ export const CLI_AGENT_TYPES = [ "dsh", ] as const; export type CliAgentType = (typeof CLI_AGENT_TYPES)[number]; + +// What an install offers before anyone saves a choice. dsh stays opt-in: it +// needs the harness binary and a provider key on the server, and a curious +// click without either should not be the first thing a new install sees. +export const DEFAULT_ENABLED_AGENT_TYPES = AGENT_TYPES.filter( + (type) => type !== "dsh" +) as readonly Exclude[]; diff --git a/packages/shared/src/chat-types.ts b/packages/shared/src/chat-types.ts index 62d78e42d..695ab8334 100644 --- a/packages/shared/src/chat-types.ts +++ b/packages/shared/src/chat-types.ts @@ -179,6 +179,8 @@ export type ChatAssistantEntry = { text: string; /** True while chunks are still arriving for this message. */ streaming: boolean; + /** The text hit the server's per-message size bound and was cut. */ + truncated?: boolean; at: string; }; @@ -202,6 +204,8 @@ export type ChatActivityEntry = { locations: { path: string; line?: number }[]; diff: { path: string; oldText: string | null; newText: string } | null; terminalOutput: string | null; + /** Output or diff hit the server's per-row size bound and was cut. */ + truncated?: boolean; at: string; }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 98f52ca03..f3389d6a4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,7 +7,11 @@ * and in the compiled server binary at once, so keep those to plain constants * that both sides genuinely have to agree on. */ -export { AGENT_TYPES, CLI_AGENT_TYPES } from "./agent-types.js"; +export { + AGENT_TYPES, + CLI_AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, +} from "./agent-types.js"; export type { AgentType, CliAgentType } from "./agent-types.js"; export type { AgentGitContext, diff --git a/scripts/e2e-isolated.sh b/scripts/e2e-isolated.sh index 02ad1bbbc..2e64fb613 100755 --- a/scripts/e2e-isolated.sh +++ b/scripts/e2e-isolated.sh @@ -93,7 +93,7 @@ cleanup() { done || true fi $COMPOSE -p "$PROJECT" down -v 2>/dev/null || true - rm -rf "$MEDIA_ROOT" + rm -rf "$MEDIA_ROOT" "$DISPATCH_DSH_HOME" rm -f "$DISPATCH_RELEASE_STORE_PATH" "$DISPATCH_RELEASE_CANDIDATE_STORE_PATH" } trap cleanup EXIT diff --git a/update-migrations/0012-agent-stream-events.yaml b/update-migrations/0012-agent-stream-events.yaml new file mode 100644 index 000000000..6b1d095ca --- /dev/null +++ b/update-migrations/0012-agent-stream-events.yaml @@ -0,0 +1,45 @@ +id: agent-stream-events +title: Verify agent_stream_events migration and dsh agent restart +summary: > + Adds the `agent_stream_events` table that stores the Chat-tab stream of + the new `dsh` (DeepSeek Harness) agent type: assistant messages, thoughts, + tool calls, and status rows folded from the harness's Agent Client Protocol + session. The table is additive and the migration runs automatically on + server boot. The release also adds the `dsh` agent type (opt-in: it is not + in the default enabled list), the `@agentclientprotocol/sdk` dependency, + and a child-process supervisor. A dsh agent's harness process does not + survive a service restart; the new process resumes running dsh agents on + their stored session ids and marks any it cannot resume as errored. + +alreadySatisfied: + description: > + The install is already on the target tag, the local health endpoint + returns status=ok, and release.json reports the target tag. Server boot + runs the migration idempotently and the table is additive, so any + healthy install at the target tag has already taken it. Running dsh + agents, if any existed, were restored or marked errored at boot. + +instructions: + - Confirm the service has been restarted to the target release tag. + - Confirm $DISPATCH_API_URL/api/v1/health returns status=ok. + - Confirm release.json under the install directory reports the target tag. + - Expect any dsh agents that were running before the restart to show + either "dsh session resumed." or an error event saying dsh did not come + back; the latter means Start must be pressed again once `dsh` is on the + server's PATH (DISPATCH_DSH_BIN) and a provider key is in the server env. + - Since alreadySatisfied is true on every healthy install at the target tag, + do not perform any change steps — proceed straight to the validate phase. + +validation: + requiredChecks: + - service_restarted + - health_endpoint + - version_converged + +rollback: + - If the new release does not return healthy, roll back to the previous + release tag using the normal release rollback path for this host. The + agent_stream_events table is additive — the prior release ignores it, and + leaving it in place is harmless and safer than dropping it. + - Restart the service and confirm $DISPATCH_API_URL/api/v1/health returns + status=ok and release.json reports the prior tag. From e3409a64e0aa5730da8f7e99ac233100a297d300 Mon Sep 17 00:00:00 2001 From: Nii Yeboah Date: Fri, 4 Sep 2026 13:22:04 -0700 Subject: [PATCH 016/254] fix(dsh): Chat tab rendering from UX review, plus three reviewer nits Activity rows no longer swallow the agent's header: layoutFeed leaves lastPost untouched for them, and the first row of a run renders inside a Post with the agent's avatar. Streaming rows version by text length so the pane keeps following as a reply grows, without flickering the new-messages pill. The diff is a line-aligned LCS with coloured +/- rows and treats a null old text as an empty file. Activity rows are buttons only when expandable, announce title and state, pulse while in progress, and mark a failure with an icon and colour; paths show relative and keep the title readable. The writing indicator is a labelled status row that respects reduced motion. Truncated rows say so. Also: the turn chain survives a rejected turn, a spawn failure reports the child's exit reason over the aborted handshake, and per-row writes are chained so a timer flush cannot land after close. The unread gap for stream entries is recorded in the spec. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/agents/dsh/driver.ts | 11 +- apps/server/src/agents/dsh/stream-recorder.ts | 12 +- apps/server/src/agents/dsh/supervisor.ts | 20 +- .../components/app/chat/chat-feed.test.tsx | 134 ++++++++- .../web/src/components/app/chat/chat-feed.tsx | 22 +- .../web/src/components/app/chat/chat-pane.tsx | 17 +- .../app/chat/stream-entries.test.tsx | 30 ++ .../components/app/chat/stream-entries.tsx | 279 ++++++++++++++---- .../specs/2026-09-04-dsh-harness-design.md | 5 + 9 files changed, 444 insertions(+), 86 deletions(-) create mode 100644 apps/web/src/components/app/chat/stream-entries.test.tsx diff --git a/apps/server/src/agents/dsh/driver.ts b/apps/server/src/agents/dsh/driver.ts index a491bba9b..87fea8886 100644 --- a/apps/server/src/agents/dsh/driver.ts +++ b/apps/server/src/agents/dsh/driver.ts @@ -216,6 +216,7 @@ export class DshDriver { // missing cwd) is an `error` event with no `exit`, and an unhandled one // would take the whole server down. const stderrTail: string[] = []; + let settledExit: ExitInfo | null = null; const exited = new Promise((resolve) => { child.on("exit", (code, signal) => resolve({ code, signal: signal ?? null }) @@ -224,6 +225,9 @@ export class DshDriver { resolve({ code: null, signal: null, error }) ); }); + void exited.then((exit) => { + settledExit = exit; + }); child.stderr?.on("data", (chunk: Buffer) => { for (const line of chunk.toString("utf8").split("\n")) { if (!line.trim()) continue; @@ -335,8 +339,13 @@ export class DshDriver { if (!outcome.ok) { handshake.catch(() => {}); child.kill("SIGKILL"); + // A spawn failure aborts the handshake too, and that rejection can win + // the race; the child's own exit reason is the useful one. + const reason = settledExit + ? `${describeExit(settledExit)} during startup` + : outcome.reason; const tail = stderrTail.length ? `\n${stderrTail.join("\n")}` : ""; - throw new Error(`dsh start failed: ${outcome.reason}${tail}`); + throw new Error(`dsh start failed: ${reason}${tail}`); } const entry: Live = { diff --git a/apps/server/src/agents/dsh/stream-recorder.ts b/apps/server/src/agents/dsh/stream-recorder.ts index 7355d4a0f..4cfaeb123 100644 --- a/apps/server/src/agents/dsh/stream-recorder.ts +++ b/apps/server/src/agents/dsh/stream-recorder.ts @@ -16,6 +16,8 @@ type OpenText = { /** Text as last written; a flush is a no-op when nothing changed. */ written: string; flushTimer: NodeJS.Timeout | null; + /** Writes for this row run in order: a timer flush never lands after close. */ + writing: Promise; }; /** Model output is not trusted input: bound what one row can hold. */ @@ -251,10 +253,11 @@ export class StreamRecorder { } if (current.written === current.text && streaming) return; current.written = current.text; - await this.store.updatePayload( - current.row.id, - this.payloadFor(kind, current, streaming) - ); + const payload = this.payloadFor(kind, current, streaming); + current.writing = current.writing + .catch(() => {}) + .then(() => this.store.updatePayload(current.row.id, payload)); + await current.writing; } private async appendText( @@ -281,6 +284,7 @@ export class StreamRecorder { truncated: false, written: delta, flushTimer: null, + writing: Promise.resolve(), }; state[kind] = current; this.open.set(agentId, state); diff --git a/apps/server/src/agents/dsh/supervisor.ts b/apps/server/src/agents/dsh/supervisor.ts index 5c04d035e..894c2f9b8 100644 --- a/apps/server/src/agents/dsh/supervisor.ts +++ b/apps/server/src/agents/dsh/supervisor.ts @@ -224,14 +224,18 @@ export class DshSupervisor { markStarted = resolve; }); const prior = this.turns.get(agentId) ?? Promise.resolve(); - const run: Promise = prior.then(() => - this.runTurn( - agentId, - text, - markStarted, - () => this.turns.get(agentId) === run - ) - ); + // runTurn only rejects if the pre-try status write throws; never let + // that skip the next turn and strand its `started`. + const run: Promise = prior + .catch(() => {}) + .then(() => + this.runTurn( + agentId, + text, + markStarted, + () => this.turns.get(agentId) === run + ) + ); this.turns.set(agentId, run); void run.finally(() => { if (this.turns.get(agentId) === run) this.turns.delete(agentId); diff --git a/apps/web/src/components/app/chat/chat-feed.test.tsx b/apps/web/src/components/app/chat/chat-feed.test.tsx index 765b61d18..b82a81c15 100644 --- a/apps/web/src/components/app/chat/chat-feed.test.tsx +++ b/apps/web/src/components/app/chat/chat-feed.test.tsx @@ -23,6 +23,7 @@ import { latestOpenFreeformQuestion, latestUserMessageId, layoutFeed, + entryVersion, } from "@/components/app/chat/chat-feed"; // Mermaid + the copy hook touch browser APIs jsdom lacks; neither is under @@ -1404,7 +1405,7 @@ describe("stream entries", () => { expect(screen.getByLabelText("completed")).toBeTruthy(); }); - it("shows a streaming indicator while an assistant message is open", () => { + it("shows a labelled writing indicator while an assistant message is open", () => { renderFeed([ { type: "assistant", @@ -1414,7 +1415,125 @@ describe("stream entries", () => { at: "2026-09-04T10:00:00.000Z", }, ]); - expect(screen.getByLabelText("streaming")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toContain("Writing"); + }); + + it("renders a non-expandable activity row as plain text, not a button", () => { + renderFeed([ + { + type: "activity", + id: "stream:9", + toolKind: "read", + title: "Read README.md", + status: "in_progress", + locations: [{ path: "README.md" }], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:00.000Z", + }, + ]); + expect(screen.queryByRole("button", { name: /Read README.md/ })).toBeNull(); + expect(screen.getByLabelText("in progress")).toBeTruthy(); + }); + + it("keeps the agent header on the assistant post that follows a tool run", () => { + const rows = layoutFeed( + [ + status("s1", "working", "Working", "2026-09-04T10:00:00.000Z"), + { + type: "activity", + id: "stream:1", + toolKind: "read", + title: "Read a", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:01.000Z", + }, + { + type: "activity", + id: "stream:2", + toolKind: "read", + title: "Read b", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:02.000Z", + }, + { + type: "assistant", + id: "stream:3", + text: "Done.", + streaming: false, + at: "2026-09-04T10:00:03.000Z", + }, + ], + makeCtx(), + new Date("2026-09-04T12:00:00.000Z") + ); + const entries = rows.filter((r) => r.kind === "entry"); + expect(entries.map((r) => [r.entry.id, r.grouped])).toEqual([ + ["stream:1", false], + ["stream:2", false], + ["stream:3", false], + ]); + }); + + it("groups a second assistant post under the first even across tool rows", () => { + const rows = layoutFeed( + [ + { + type: "assistant", + id: "stream:1", + text: "First.", + streaming: false, + at: "2026-09-04T10:00:00.000Z", + }, + { + type: "activity", + id: "stream:2", + toolKind: "edit", + title: "Edit x", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + at: "2026-09-04T10:00:01.000Z", + }, + { + type: "assistant", + id: "stream:3", + text: "Second.", + streaming: false, + at: "2026-09-04T10:00:02.000Z", + }, + ], + makeCtx(), + new Date("2026-09-04T12:00:00.000Z") + ); + const entries = rows.filter((r) => r.kind === "entry"); + expect(entries.map((r) => [r.entry.id, r.grouped])).toEqual([ + ["stream:1", false], + ["stream:2", true], + ["stream:3", true], + ]); + }); + + it("versions a streaming assistant row by its text so growth is visible", () => { + const base = { + type: "assistant" as const, + id: "stream:1", + streaming: true, + at: "2026-09-04T10:00:00.000Z", + }; + expect(entryVersion({ ...base, text: "a" })).not.toBe( + entryVersion({ ...base, text: "ab" }) + ); + expect(entryVersion({ ...base, text: "ab" })).not.toBe( + entryVersion({ ...base, text: "ab", streaming: false }) + ); }); it("expands an activity row to show its diff", () => { @@ -1431,10 +1550,15 @@ describe("stream entries", () => { at: "2026-09-04T10:00:02.000Z", }, ]); - fireEvent.click(screen.getByRole("button", { name: /Edit index.ts/ })); + fireEvent.click( + screen.getByRole("button", { name: "Edit index.ts, completed" }) + ); const row = screen.getByTestId("chat-activity"); expect(row.getAttribute("data-status")).toBe("completed"); - expect(row.textContent).toContain("- b"); - expect(row.textContent).toContain("+ c"); + const diff = screen.getByTestId("chat-activity-diff"); + const kinds = [...diff.querySelectorAll("[data-kind]")].map( + (el) => `${el.getAttribute("data-kind")}:${el.textContent?.trim()}` + ); + expect(kinds).toEqual(["same:a", "del:-b", "add:+c"]); }); }); diff --git a/apps/web/src/components/app/chat/chat-feed.tsx b/apps/web/src/components/app/chat/chat-feed.tsx index a83e686fa..d7dcb1d4c 100644 --- a/apps/web/src/components/app/chat/chat-feed.tsx +++ b/apps/web/src/components/app/chat/chat-feed.tsx @@ -54,9 +54,21 @@ export type ChatFeedRow = /** Posts by one author this close together share a header, like Slack. */ const GROUP_WINDOW_MS = 5 * 60 * 1000; -/** What "the same entry, changed" means: a post edited in place has a new one. */ -function entryVersion(entry: ChatFeedEntry): string { - return entry.type === "chat" ? entry.message.updatedAt : entry.at; +/** + * What "the same entry, changed" means: a post edited in place has a new + * one, and so does a stream row that grew or settled since the last render. + */ +export function entryVersion(entry: ChatFeedEntry): string { + switch (entry.type) { + case "chat": + return entry.message.updatedAt; + case "assistant": + return `${entry.at}:${entry.text.length}:${entry.streaming ? 1 : 0}`; + case "activity": + return `${entry.at}:${entry.status}:${entry.terminalOutput?.length ?? 0}:${entry.diff ? 1 : 0}`; + default: + return entry.at; + } } /** @@ -235,6 +247,10 @@ export function layoutFeed( at - lastPost.at <= GROUP_WINDOW_MS; const rule = !grouped && rows[rows.length - 1]?.kind === "entry"; rows.push({ kind: "entry", entry: item.entry, grouped, rule }); + // Tool activity rides under the agent's current post without becoming + // one: the assistant text that follows a tool run still opens with the + // agent's avatar and name instead of trailing headerless. + if (item.entry.type === "activity") continue; lastPost = { key, at: Number.isFinite(at) ? at : 0 }; } return rows; diff --git a/apps/web/src/components/app/chat/chat-pane.tsx b/apps/web/src/components/app/chat/chat-pane.tsx index ff1fa2d9d..d854cc1c3 100644 --- a/apps/web/src/components/app/chat/chat-pane.tsx +++ b/apps/web/src/components/app/chat/chat-pane.tsx @@ -23,6 +23,7 @@ import { latestAgentMessageId, latestOpenFreeformQuestion, latestUserMessageId, + entryVersion, } from "@/components/app/chat/chat-feed"; import { type Agent, @@ -170,6 +171,8 @@ export function ChatPane({ const [following, setFollowing] = useState(true); const [pendingBelow, setPendingBelow] = useState(false); const lastEntryIdRef = useRef(null); + /** Id plus version of the tail entry: a streaming row grows in place. */ + const lastEntryKeyRef = useRef(null); const lastShowChildAgentsRef = useRef(showChildAgents); const olderLoadRef = useRef<{ height: number; top: number } | null>(null); @@ -206,7 +209,9 @@ export function ChatPane({ olderLoadRef.current = null; return; } - const lastId = visibleEntries[visibleEntries.length - 1]?.id ?? null; + const last = visibleEntries[visibleEntries.length - 1]; + const lastId = last?.id ?? null; + const lastKey = last ? `${last.id}:${entryVersion(last)}` : null; const filterChanged = lastShowChildAgentsRef.current !== showChildAgents; lastShowChildAgentsRef.current = showChildAgents; // Changing the filter can expose an older tail or remove the current one. @@ -214,15 +219,20 @@ export function ChatPane({ // manufacture a “New messages” prompt or move the scroll position. if (filterChanged) { lastEntryIdRef.current = lastId; + lastEntryKeyRef.current = lastKey; setPendingBelow(false); return; } const appended = lastId !== lastEntryIdRef.current; + // A streaming assistant row keeps its id while its text grows; that is + // still new content below the fold for a reader who is following. + const grew = !appended && lastKey !== lastEntryKeyRef.current; lastEntryIdRef.current = lastId; - if (!appended) return; + lastEntryKeyRef.current = lastKey; + if (!appended && !grew) return; if (following) { scrollToBottom(); - } else { + } else if (appended) { setPendingBelow(true); } }, [following, scrollToBottom, showChildAgents, visibleEntries]); @@ -232,6 +242,7 @@ export function ChatPane({ setFollowing(true); setPendingBelow(false); lastEntryIdRef.current = null; + lastEntryKeyRef.current = null; olderLoadRef.current = null; }, [agentId]); diff --git a/apps/web/src/components/app/chat/stream-entries.test.tsx b/apps/web/src/components/app/chat/stream-entries.test.tsx new file mode 100644 index 000000000..f463fddf1 --- /dev/null +++ b/apps/web/src/components/app/chat/stream-entries.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { diffLines } from "@/components/app/chat/stream-entries"; + +describe("diffLines", () => { + it("aligns an insertion without marking every following line", () => { + const out = diffLines("a\nb\nc", "x\na\nb\nc"); + expect(out).toEqual([ + { kind: "add", text: "x" }, + { kind: "same", text: "a" }, + { kind: "same", text: "b" }, + { kind: "same", text: "c" }, + ]); + }); + + it("treats a null old text as an empty file", () => { + expect(diffLines(null, "one\ntwo")).toEqual([ + { kind: "add", text: "one" }, + { kind: "add", text: "two" }, + ]); + }); + + it("marks a replaced line as one removal and one addition", () => { + expect(diffLines("a\nb", "a\nc")).toEqual([ + { kind: "same", text: "a" }, + { kind: "del", text: "b" }, + { kind: "add", text: "c" }, + ]); + }); +}); diff --git a/apps/web/src/components/app/chat/stream-entries.tsx b/apps/web/src/components/app/chat/stream-entries.tsx index 01b59a970..d9142ac5b 100644 --- a/apps/web/src/components/app/chat/stream-entries.tsx +++ b/apps/web/src/components/app/chat/stream-entries.tsx @@ -1,3 +1,4 @@ +import { AlertTriangle } from "lucide-react"; import { useState } from "react"; import type { ChatActivityEntry, ChatAssistantEntry } from "@dispatch/shared"; @@ -38,47 +39,134 @@ export function AssistantEntryView({ {entry.text} {entry.streaming ? ( + role="status" + className="mt-1 inline-flex items-center gap-1.5 text-[11px] text-muted-foreground" + > + + ) : null} + {entry.truncated ? ( +

+ Message truncated: it passed the per-message size limit. +

) : null} ); } +export type DiffLine = { kind: "same" | "add" | "del"; text: string }; + +/** + * Line-aligned diff over the two texts (longest common subsequence). Bounded: + * past the cell budget it falls back to "everything removed, everything + * added", which is still honest, just less pretty. A null old text is an + * empty file, so a new file is pure additions. + */ +export function diffLines(oldText: string | null, newText: string): DiffLine[] { + const a = oldText === null || oldText === "" ? [] : oldText.split("\n"); + const b = newText === "" ? [] : newText.split("\n"); + const CELL_BUDGET = 250_000; + if (a.length * b.length > CELL_BUDGET) { + return [ + ...a.map((text) => ({ kind: "del" as const, text })), + ...b.map((text) => ({ kind: "add" as const, text })), + ]; + } + // lcs[i][j] = length of the LCS of a[i..] and b[j..] + const rows = a.length + 1; + const cols = b.length + 1; + const lcs = new Uint32Array(rows * cols); + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + lcs[i * cols + j] = + a[i] === b[j] + ? lcs[(i + 1) * cols + j + 1] + 1 + : Math.max(lcs[(i + 1) * cols + j], lcs[i * cols + j + 1]); + } + } + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + out.push({ kind: "same", text: a[i] }); + i += 1; + j += 1; + } else if (lcs[(i + 1) * cols + j] >= lcs[i * cols + j + 1]) { + out.push({ kind: "del", text: a[i] }); + i += 1; + } else { + out.push({ kind: "add", text: b[j] }); + j += 1; + } + } + while (i < a.length) out.push({ kind: "del", text: a[i++] }); + while (j < b.length) out.push({ kind: "add", text: b[j++] }); + return out; +} + +const DIFF_LINE_CLASS: Record = { + same: "text-muted-foreground", + add: "bg-status-done/10 text-status-done", + del: "bg-status-blocked/10 text-status-blocked", +}; +const DIFF_SIGN: Record = { + same: " ", + add: "+", + del: "-", +}; + +function DiffBlock({ + oldText, + newText, +}: { + oldText: string | null; + newText: string; +}): JSX.Element { + const lines = diffLines(oldText, newText); + return ( +
+      {lines.map((line, index) => (
+        
+ + {line.text} +
+ ))} +
+ ); +} + const ACTIVITY_STATUS_CLASS: Record = { pending: "bg-muted-foreground/40", - in_progress: "bg-status-working", + in_progress: "animate-pulse bg-status-working motion-reduce:animate-none", completed: "bg-status-done", failed: "bg-status-blocked", }; -/** A line-by-line diff for an activity card; enough for a prototype view. */ -export function renderUnifiedDiff(oldText: string, newText: string): string { - const a = oldText.split("\n"); - const b = newText.split("\n"); - const out: string[] = []; - const max = Math.max(a.length, b.length); - for (let i = 0; i < max; i += 1) { - if (a[i] === b[i]) { - out.push(` ${a[i] ?? ""}`); - continue; - } - if (i < a.length) out.push(`- ${a[i]}`); - if (i < b.length) out.push(`+ ${b[i]}`); - } - return out.join("\n"); -} - /** - * One tool call from the harness stream: a compact row under the agent's - * posts, expandable when it carries a diff or terminal output. + * One tool call from the harness stream. A compact row under the agent's + * posts; the first row of a run carries the agent header so the reader + * knows who is acting. Expandable only when it has a diff or output. */ export function ActivityEntryView({ entry, grouped, rule = false, + ctx, }: { entry: ChatActivityEntry; grouped: boolean; @@ -88,53 +176,120 @@ export function ActivityEntryView({ const [open, setOpen] = useState(false); const expandable = entry.diff !== null || entry.terminalOutput !== null; const location = entry.locations[0]?.path ?? null; + const failed = entry.status === "failed"; + const status = entry.status.replace("_", " "); + const rowClass = cn( + "flex min-h-7 min-w-0 items-center gap-2 py-1 text-left text-xs text-muted-foreground", + expandable ? "cursor-pointer hover:text-foreground" : "cursor-default" + ); + const body = ( + <> + + + {failed ? ( +