feat: Dispatch Harness, an ACP agent type with four engines and a turn-stream view - #1067
Open
niiyeboah wants to merge 252 commits into
Open
feat: Dispatch Harness, an ACP agent type with four engines and a turn-stream view#1067niiyeboah wants to merge 252 commits into
niiyeboah wants to merge 252 commits into
Conversation
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ifecycle 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 <noreply@anthropic.com>
…e pane Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
entryVersion keys the enter animation and must not change per chunk; the pane's follow logic keys on a separate entryGrowthKey that adds text length and status. DiffBlock memoizes its alignment across feed refetches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Activity rows group under the previous agent row (post or activity); the assistant post after a run still opens its own group. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e default model restoreRunning ran inside initializeApp, before app.listen, so a resumed harness could not attach Dispatch's MCP endpoint and every restore failed. It now runs from start() after listen. A fresh dsh session receives the agent's launch-context post as its first turn (dsh takes no launch argument). With no model chosen, the overlay picks a model whose provider key the service has instead of the profile's DeepSeek default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The create dialog offers the model picker for dsh; the Agent pane opens on Chat for a dsh agent whether or not the global chat-surface flag is on, quick prompts route through Chat for it, its launch post is durable, and its launch guidance assumes Chat. The type icon is the fish, dsh's own mark, instead of a text badge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The launch guidance for dsh drops the pane-era chat rule (which sent every reply through dispatch_chat_post, so the first live turn answered twice) and states that replies stream into Chat, with dispatch_chat_post only for a question with options. dsh's ACP tool calls carry no kind, so the recorder infers one from the tool name. dsh reports no token counts on the wire; recorded in the spec with the harvester fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Dispatch tokens Contracts, pure reducer, formatting helpers and the braille stream ticker come over from @mytraai/promptkit trimmed to what dsh emits. The six primitives (PromptLine, StepRow, StepDetail, ActivityBlock, ResultTurn, TurnStream) are rewritten on shadcn and Dispatch's status tokens, with a module registry in place of the React context. Tailwind gains the three harness keyframes. Also rebrands the agent type: 'Dispatch' in menus, 'Dispatch Harness' in settings, and the Dispatch brand mark in place of the fish icon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pane The turn stream (PromptKit port) becomes the default view for a Dispatch Harness agent, with Chat and Console behind a three-segment toggle. Turns come from the harness turns route, refresh on chat.changed, and prompts go through the chat composer so cross-agent messaging is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on it The stored pane view now starts as null so a Dispatch Harness agent opens on the Harness view instead of the atom's old "chat" default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ersion Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Enter queueing, ArrowUp recall, Ctrl+C, the engine's slash commands and the "@" path picker were the harness pane's, so a dispatch agent lost all of them the moment its pane became the chat pane. They arrive as extra ChatComposer props from the chrome hook, absent for every other type. Also lands the pane-wide drop overlay task 2 deferred: its state had no caller until these handlers existed, and eslint fails an unused setter. The queue mocks are cleared rather than reset now: on Vitest 2 a reset drops the async body, so interrupt() returned undefined and onStop's .catch threw an uncaught error out of every Stop case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One boolean swapped a dispatch agent's whole feed layer for a second surface, so it had no reviews, pins, presence, day dividers, copy action, unread badge or child-agent filter. The pane is ChatPane for every type now; the Console segment and the rule that forces Chat on for a dispatch agent are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing mounts them: a dispatch agent's pane is ChatPane, its turns are feed entries and its questions are chat rows with Brad's card, which is where they can be answered and read in one place. The turn stream had one consumer and goes with the pane. latestPlanItems goes too. Plan 3 task 1 called for it, but the pane this task deletes was its last caller, so removing it any earlier would have dropped the tasks strip from a surface that still shipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pane a dispatch agent opens is the chat pane, its turns are feed entries with a settled flag and its queue sits above the composer, so every harness-pane selector in the spec matched nothing. Same four tests, same fake ACP agent, new targets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dispatch agent had no reviews, pins, presence, unread or day dividers because it never reached the chat feed. This seeds a turn into the stream table (the inert suite starts no engine), then a review, a pin write and a status event, and asserts all four render in one feed with the tasks strip above the composer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The turns query was a second cache over agent_stream_events, and five coarse events refetched it plus the session config alongside the chat feed. The turn entries in the feed replaced it, so the module goes and each of those events now invalidates only the feed it actually changed. A reconnect sweeps the queue key in the turns key's place, since the queue rode on the turns response. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server stops listing a chat row a turn claims as its prompt, but the client already held that row from its own send, so the prompt rendered twice until some later event refetched the feed. upsertFeedEntry now prunes the claimed row before placing the turn. Nothing can put it back: the send's success path only replaces rows the cache already holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
harness.changed fires on every recorder flush, up to ten times a second during a turn, and each one refetched the whole feed page. The turn it changed now arrives as its own chat.entry carrying the whole entry, so a chunk costs one row upsert. The event keeps the queue and the conditional session-config reads, which no row event covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two readers walked agent_stream_events and produced different shapes: the chat feed's turn entries and this endpoint's HarnessTurn objects. The feed serves a dispatch agent's turns now, so the route and loadTurns go, and loadQueued keeps the queue route it was already sharing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No wire message carries a turn in the HarnessTurn shape since the turns endpoint went: the feed carries ChatTurnEntry. The assembler keeps an intermediate shape because toTurnEntry derives the entry's agent id, timestamps and settled state from the group, but that shape is now a local AssembledTurn, and the step and plan aliases go with it. The assembly suite is renamed after the module it tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its only outside caller was the turn stream that rendered a live turn's text beside the rail. TurnEntryView renders that text through ResultTurn, so the helper is private to its own file again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release notes, the runbook and one line of Settings copy described a Harness view and an Agent-types opt-in, neither of which ships: a dispatch agent reads in the Chat feed, behind the new Dispatch Harness setting. Four more references outrun the plan's list. Three name a view that is deleted rather than moved, in the release notes' motion bullet and in two doc comments; the fourth, on the publishHarness hook, still promised that this plan would drop the coarse event, which it narrowed instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The turn payload's prompt path was spelled twice, once in TypeScript off TurnPayload and once as a SQL literal in the feed's anti-join. Only one of them is compiler-checked, and a rename would have left every chat prompt rendering twice, so the path now has one owner beside its TypeScript reader. HarnessQuestion had no reader outside the server's assembler and no wire message carried it, so it moves next to AssembledTurn as AssembledQuestion rather than sitting in the client/server contract package inviting a client to import it. ResultTurn's showTime and isStreaming were both unreachable: its one caller pins showTime false and never passes isStreaming, and the streaming placeholder went with the turn stream. useHarnessQueued and useHarnessQueue differed by one character in the same module and were called on adjacent lines; the query is useQueuedPrompts now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reviewers found the anti-join shipping without an index. Nothing covered kind or the prompt path, so Postgres answered it with a sequential scan that was not even restricted to the agent: one agent's feed page slowed down as a different agent accumulated rows. Measured on a synthetic 60k-row agent, 64.6ms and 8576 buffers become 2.5ms and 413. Two reviewers found the publish racing itself. Each recorder flush composes the whole open turn, so two composes overlap on a slow disk and the older one can publish last, leaving a turn short and still marked streaming. Nothing corrected it, because that turn's own event no longer refetches the feed and the flush that settled it was the last one. There is now one compose per agent at a time with a single trailing re-run, and the client ignores a turn whose updatedAt is older than the row it already holds. A prerelease install also lost its harness opt-in on update, silently: the type was turned on by adding dispatch to enabled_agent_types, which is now stripped on read, and a running dispatch parent's persona launch would start throwing mid-turn. A migration carries the old choice onto the new flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn is anchored where it started, so the status event an agent emits mid-turn becomes the tail entry while the turn keeps growing. Following read growth off the tail alone, so a reader pinned to the bottom drifted off it the moment the agent reported its first phase, which CLAUDE.md has every agent do. ArrowUp took the newest queued prompt whatever its source, so a child agent's message queued behind a running turn could be deleted undelivered and land in the user's draft to be re-sent as their own. It takes the newest chat-sourced one now, the same rule the history walk beside it already used. Template launches and job runs read an agent type straight from their request body and reached createAgent without consulting the offered list, so dispatch agents were creatable with the harness flag off. The gate moves to createAgent, which every creation path goes through, before the media directory is made. Two E2E defects: both harness specs left a server-wide flag on, which the settings assertion only survived by file ordering, and the turn seeder's seq arithmetic double-counted, writing 1, 3, 6, 10 where the recorder writes 1, 2, 3, 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folding the harness into the chat pane handed a dispatch agent the unread badge, the child-agent filter and the copy action. None of the three could see a turn, which is the only thing that type produces. Unread counted chat rows, and a harness answer is never one, so the badge could fire only for a question the agent asked. Turns carry no per-row read state, so agents gain a chat_read_at watermark: a full mark-read moves it, and both the pane count and the sidebar summary now add the turns that settled after it. Existing rows are stamped at migration time so a backlog does not light up every badge at once. The child filter matched agent_message entries only, so hiding a child conversation hid the outbound half and left the turn the child's message drove. ChatTurnPrompt carries senderAgentId now, which the server had already parsed and thrown away. A cross-agent message rendered twice: once as its own row, once as the turn's prompt, in two cards that disagreed, and minutes apart whenever the prompt had queued. The turn stops drawing its own copy; the row that carries the sender's badge and id is the single rendering. Also: the turn's answer gets the copy button its prompt always had, the empty state stops telling a dispatch user to look in a Console its agent never writes to, the status line stops repeating the sentence the composer prints under the field, and the queue hint says which message ArrowUp takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes the reviewers found in the batch before this one. The read watermark never moved. It was gated on an unbounded read, but the pane always sends a bound (the newest agent message it holds), so the gate never fired and a settled turn stayed unread forever. Every read moves it now, and it moves to now rather than to the bound: on a harness agent that bound can be far older than the turns under it, so honouring it would leave them unread just as permanently. A route test covers the path the pane actually takes; the store test did not. A settled turn also invalidated nothing, so the badge waited for a refocus to appear. The chat.entry handler counts one now. Filtering child agents hid a child-started turn, which was an overreach once the turn stopped rendering the child's words: what is left is the parent's own rail and answer, no other agent type's filter hides the agent's own output, and an agent driven entirely by children would show an empty pane. Reverted; senderAgentId stays on the wire. Composing a turn entry reads the whole open turn and the recorder asks about ten times a second, so a server with no browser attached was paying for an announcement nobody received. It skips when no client is connected; a reconnecting one refetches from the rows anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
niiyeboah
force-pushed
the
agt_683b115bc1e9/dispatch-harness-research
branch
from
September 11, 2026 06:52
9a9e337 to
3d5480e
Compare
niiyeboah
marked this pull request as ready for review
September 11, 2026 06:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the Dispatch Harness: a
dispatchagent type that runs a coding agent as a child process over the Agent Client Protocol and renders the session as turns in a new Harness view. Four engines ship behind one seam, chosen by the model id prefix: Claude Code (claude/), Codex (codex/), Gemini CLI (gemini/), and OpenCode (opencode/). Each is the CLI already installed and logged in on the server; Dispatch holds no provider key.Changes
apps/server/src/agents/harness/agent-spec.ts: argv, env, permission mode, and persona delivery per engine;splitModelIdpicks the engine from the model id.apps/server/src/agents/harness/read ACP v1 updates (plan,plan_update,usage_update,available_commands_update, nested tool calls) and carry plan, usage, and children onto turns; anauth_requiredfailure or Claude's "Please run /login" reply ends the session with " is not logged in on the server".apps/server/src/routes/agents/harness-routes.tsandroutes/system.ts:/harness/commandsper agent, month-to-date usage by engine, a static engine-prefixed model catalog inapps/server/src/shared/agent-models.ts.0051_agent-stream-eventsand0052_agent-chat-messages-delivery-text(both additive and guarded); assisted-update manifestupdate-migrations/0012-agent-stream-events.yamlrewritten.packages/shared/src/harness-types.ts:HARNESS_ENGINES(labels, what each publishes, login command),HarnessUsageReport, budgets keyed by engine.apps/web/src/components/app/harness/: tasks strip fromturn.plan, slash menu from the engine's commands, nested subagent steps, engine marks and a model chip that says when an engine fixes its model, usage and budgets by engine, a login hint on the starting screen, a harness icon, and every transition on one set of motion tokens (motion.ts) with reduced-motion support.e2e/fixtures/fake-acp-agent.mjsspeaks all four dialects;e2e/harness-agent.spec.tsruns once per engine.Test plan
pnpm run check(server, web, site, extension, scripts) exits 0.scripts/server-tests-isolated.sh run).pnpm run finalize:webbuilds.pnpm run test:e2e198 passed, 19 expected skips;pnpm run test:e2e:liveruns the terminal and harness specs, with the harness spec 7 of 7 across the four engines against the fake ACP agent (one pre-existing terminal shortcut test cannot run on a Linux host)..superpowers/sdd/in the working tree, not in the PR.Live smoke, 2026-09-08
Ran
claude/default,codex/default, andgemini/defaultagainst the real engine binaries on an isolated stack from this branch. All three pass the per-engine checklist: prompt and result lines, tool steps,/modeland/usage, and Stop interrupting a running turn. Claude's subagent steps nest under their parent step (the real engine titles the parent by the subagent's instruction, not "Task"). Gemini's fixed-model chip and disabled picker behave as documented, and its usage row says the engine reports nothing over ACP.Two engine facts worth knowing: the ACP-bridged Claude Code session exposes no todo tool, so Claude never publishes a plan and the tasks strip stays unmounted for it; Codex reads files through
bash, so its steps carry no file-location badge.Restart resilience: a graceful restart (SIGTERM to the server, which is what the systemd unit's
KillMode=processdoes) marks the running turn interrupted, tears the engine child down in order, resumes the session at boot, runs the restart prompt, and redelivers the message that was queued at the cut. Validated live on Codex (resumed) and Gemini (Gemini CLI 0.57.0 has nosession/resume, so it starts a fresh engine session and the status line says so while Dispatch keeps the turns). The smoke's first restart attempts went throughbin/dispatch-dev, which used to kill the children before the server; the script now stops the server through the process that owns the port and gives it its full graceful shutdown. Two supervisor bugs the investigation found are fixed too: a chat message was marked delivered before the engine accepted the prompt, and a turn dequeued during shutdown could miss the interrupted mark. Note for reviewers running the MCPrepo_dev_restarttool: it executes the installed copy of the dev script, not the branch's, until this release is installed.Motion pass: the starting-screen handoff, step rows landing, the turn settling into its summary, a queued message added and removed, and Stop all read as single smooth motions at normal speed, and reach the same end states under reduced motion.
Checklist
release-notes/current.md)apps/server/test/update-migrations.test.ts