Skip to content

feat(hypervisor): give the ACP bridge a serve mode for the Builds tab (#639) - #652

Open
umi-appcoder[bot] wants to merge 2 commits into
kc/639-dsh-adapterfrom
kc/639-dsh-serve
Open

feat(hypervisor): give the ACP bridge a serve mode for the Builds tab (#639)#652
umi-appcoder[bot] wants to merge 2 commits into
kc/639-dsh-adapterfrom
kc/639-dsh-serve

Conversation

@umi-appcoder

@umi-appcoder umi-appcoder Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fourth of six PRs for #639. Stacked on #651 — base is kc/639-dsh-adapter, so the diff here is just the serve mode. Still inert: nothing launches it until the registry PR.

Why the Builds tab needs more than the bridge already had

The Hypervisor drives one prompt per process. The Builds tab is different: it runs a CLI interactively in a tmux pane and pastes prompt after prompt into it. dsh has no REPL we can use for that —

  • its tui profile is not among the bundles the npm package ships (dsh-base, dsh-web-app, dsh-headless, dsh-sdk-app, dsh-sdk-minimal, dsh-acp-app — no tui), and the issue rules it out anyway;
  • --profile headless is one-shot prose with no tool output.

So the bridge grows the shape kc-harness already established for exactly this job: read stdin, answer, repeat.

--serve

Keeps one ACP connection and session alive across prompts. Not tidiness: booting dsh's plugin tree is the slow part of a turn, and a fresh session per prompt would also discard the conversation and the harness's KV-cache prefix. A failed turn does not end the session — the user can fix the key or the prompt and retry in the same pane.

Reading a tmux pane needs the idle-timeout terminator, because tmux paste-buffer types the prompt in and never sends EOF. kc-harness solved that once already, so serve mode imports harness.read_prompt rather than growing a second, subtly-different copy — both modules land in the same directory at pod boot.

--format stream-json

Renders a turn for a pane instead of for the adapter: Claude-shaped JSONL plus human-readable lines on the same stdout, the same contract kc-harness uses.

The tool blocks deliberately use the field names _claude_assistant_events / _claude_user_events in hypervisor_session.py already read — tool_use with id/name/input, tool_result with tool_use_id/content/is_error — rather than kc-harness's slightly different spelling, so a Build transcript needs no special-casing. (Worth flagging for review: harness.py's comment points at a dashboard function formatStreamJsonOutput() that no longer exists in the SPA, so in practice the pane rendering rests on the plain lines and the JSONL is parity with kc-harness. I did not change kc-harness here.)

Pretty lines are glyph-prefixed, so one can never start with { and be mistaken for an event — there is a test for that. Only a settled turn emits the closing result; the error branch emits its own, and two would read as two turns.

Both renderings share one emit() call site through a small Sink seam, so there is no second event-mapping path to drift out of sync.

Verification

10 new tests, 40 in the file: session reuse across two prompts (asserting exactly one initialize and one session/new for two session/prompts), clean exit on closed stdin and on /exit, a failed turn leaving the session usable for the next prompt, and the full stream-json shape.

Driven against a real dsh --profile acp too — two prompts and /exit over one session:

· session 15c94d2e-dadb-4290-981a-2211261765f1
{"type": "result", "result": "error: Internal error: turn failed: Authentication Fails, ..."}
✗ error  Internal error: turn failed: Authentication Fails, Your api key: ****0000 is invalid
RC=0

The auth failure is reported once and the pane exits 0 on /exit, as designed. A successful turn still needs a real DEEPSEEK_API_KEY.

Next

  1. Registry wiring — server.py (ASSISTANTS, gating, assistant_command(), model + effort tables), values.yaml, deployment.yaml, docs
  2. Orchestrator, mcp_registry, skills provider

Refs #639

🤖 Generated with Claude Code

…#639)

Fourth slice of #639, stacked on the adapter PR. Still inert — nothing
launches this yet.

The Hypervisor drives one prompt per process, but the Builds tab runs a
CLI interactively in a tmux pane and pastes prompt after prompt into it.
`dsh` has no REPL we can use for that: its `tui` profile is not among the
bundles the npm package ships (only base / web / headless / sdk /
sdk-minimal / acp), and `--profile headless` is one-shot prose. So the
bridge grows the shape kc-harness already established for exactly this
job — read stdin, answer, repeat.

`--serve` keeps ONE ACP connection and session alive across prompts. That
is not tidiness: booting dsh's plugin tree is the slow part of a turn,
and a fresh session per prompt would also discard the conversation and
the harness's KV-cache prefix. A failed turn does not end the session, so
a user can fix the key or the prompt and retry in the same pane.

Reading a tmux pane needs the idle-timeout terminator (paste never sends
EOF). kc-harness solved that once already, so serve mode imports
`harness.read_prompt` rather than growing a second, subtly-different copy
— both modules land in the same directory at pod boot.

`--format stream-json` renders a turn for a pane instead of for the
adapter: Claude-shaped JSONL plus human-readable lines on the same
stdout, same contract as kc-harness. The tool blocks deliberately use the
field names `_claude_assistant_events` / `_claude_user_events` already
read (`tool_use` with id/name/input, `tool_result` with
tool_use_id/content/is_error) rather than kc-harness's slightly different
spelling, so a Build transcript needs no special-casing. Pretty lines are
glyph-prefixed so they can never start with `{` and be mistaken for
events. Only a settled turn emits the closing `result`; the error branch
emits its own, and two would read as two turns.

Both renderings share one `emit()` call site via a small Sink seam, so
there is no second event-mapping path to drift.

10 new tests, 40 in the file: session reuse across two prompts, clean
exit on closed stdin and on `/exit`, a failed turn leaving the session
usable, and the full stream-json shape including that no plain line
starts with `{`. Also driven against a real `dsh --profile acp`: two
prompts and `/exit` over one session, rendering the auth failure once and
exiting 0.

Refs #639
@umi-appcoder

umi-appcoder Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Note on the empty checks list. CI is configured as pull_request: branches: [main], so a PR targeting another branch gets no checks at all — that is the cost of stacking, not a signal about this branch. Each PR's checks will fire once its parent merges and GitHub retargets it to main.

In the meantime everything CI runs was run locally on this exact branch: make python-tests (python3 -m unittest discover -s tests -p '*_test.py'), helm lint charts/workspace, helm unittest charts/workspace, and bash -n over the touched shell. Counts and the pre-existing boards_* import errors (which reproduce on unmodified main in that environment) are in the PR body.

Found reviewing the serve path. StreamJsonSink is reused for every prompt
in a serve-mode session, and it remembers the turn's answer so `turn_end`
can put it in the closing `result` event. Nothing cleared it at the START
of a turn, and the failure path never reaches `turn_end` at all — so:

  turn 1 fails      → _last_text = "error: …", result emitted, no turn_end
  turn 2 succeeds   → but says nothing (a tool-only turn), so turn_end
                      closes it with turn 1's error as its result

A Build pane would report a successful turn as the previous turn's
failure. Same shape with a successful turn: turn 2's result would echo
turn 1's answer.

Fixed by giving Sink a turn_start() alongside turn_end() and calling it
in prompt(), where the other per-turn state (text buffer, tool table) is
already reset. EventSink needs neither, so the adapter path is unchanged.

5 tests, driving the sink directly — the bug only surfaces on a turn that
settles without saying anything, which is awkward to script through a
stub agent and trivial to state here. They also pin that a thought is
rendered but is never mistaken for the turn's answer, and that no plain
pane line can start with `{` and be misread as an event.

Refs #639
@umi-appcoder

umi-appcoder Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a fix found reviewing this branch (fix(dsh): reset the pane renderer's answer text per turn), plus a rebase of the four PRs stacked on it.

StreamJsonSink is reused for every prompt in a serve-mode session and remembers the turn's answer so turn_end can put it in the closing result event. Nothing cleared it at the start of a turn, and the failure path never reaches turn_end at all:

turn 1 fails    → _last_text = "error: …", result emitted, no turn_end
turn 2 succeeds → but says nothing (a tool-only turn), so turn_end closes
                  it with turn 1's error as its result

A Build pane would report a successful turn as the previous turn's failure. Same shape with a successful turn: turn 2's result would echo turn 1's answer.

Fixed with a Sink.turn_start() called from prompt(), where the other per-turn state (text buffer, tool table) is already reset. EventSink needs neither, so the adapter path is unchanged. 5 new tests drive the sink directly — the bug only surfaces on a turn that settles without saying anything, which is awkward to script through a stub agent and trivial to state directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants