Commit Graph
41 Commits
Author SHA1 Message Date
Tianyi Cui 1546c6060a Merge branch 'worktree-hooks-h-retro' into worktree-hooks-i-snapshots 2026-07-04 03:31:31 +08:00
Tianyi Cui 38cd1a150c Merge branch 'worktree-hooks-e-protocol' into worktree-hooks-f-bridges
# Conflicts:
#	examples/acp-agent/cordis.snapshot.yml
#	examples/acp-agent/cordis.yml
2026-07-04 03:09:37 +08:00
Tianyi Cui 74f61c89e3 test(hooks): snapshot the CC + Codex hook matrix end-to-end
Load both hook bridges in the ACP example (dsh-hooks-claude → ./hooks.json,
dsh-hooks-codex → ./codex-hooks.json) so the full-transcript snapshot tier can
exercise each dialect against the real app. An absent config file is a silent
no-op, so a scenario carries only the file it needs and the other bridge
vanishes — verified byte-identical against every pre-existing snapshot.

Add a scenario per hook point × its headline Decision outcome, both dialects:
UserPromptSubmit block (authored, keyless) + context-fold, PreToolUse deny/ask,
PostToolUse block/context, Stop force-continue. The mid-turn scenarios are
recorded against the real API with the hook active, so the model's reaction to
a denied/blocked/force-continued turn is part of the replayed transcript.

SessionStart and SubagentStart are deliberately excluded (detached best-effort
inject races the log position — a recorded golden fails 10/10 on its own
replay), as is SubagentStop (observe-only, zero transcript footprint — a golden
could never be proven to fail). Both stay on the bridges' unit coverage. See
docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md.
2026-07-04 00:40:35 +08:00
Tianyi Cui 53b215c646 fix(tool-fs): write always renders a diff card on the completed update
A Write CREATE rendered its completed tool_call_update as the model-facing
result TEXT (`<path>…</path>…Created file`), which — because an ACP
tool_call_update.content REPLACES the call's content — clobbered the
new-file diff the pending call installed. So Zed showed the diff, then
replaced it with raw XML-ish text; only overwrite/edit looked right
(their result re-sends a diff).

write's presentResult now ALWAYS returns a diff card for a successful
write: the applied contextual hunk from `meta` when there is one
(overwrite), else an args-derived whole-file diff (`oldText: null`) for a
create or an unchanged-content overwrite. This matches claude-agent-acp,
where the create diff rides on the update and no result text replaces it.
An error still falls through to generic rendering so its message shows.
edit is unchanged (it always has a hunk; no whole-file fallback).

Re-recorded fs-write / fs-write-overwrite goldens; the create's completed
update is now a {type:'diff'} block, not the XML result text.
2026-07-03 20:52:36 +08:00
Tianyi Cui d8fd3225af feat(tool-fs): result-time applied-hunk diffs for write/edit
fs write/edit now emit a result-time contextual-diff tool_call_update
(the applied hunk with ±3 context lines, one hunk per replace_all site),
matching what claude-agent-acp sends and what makes an editor render the
change in place. The call-time snippet diff stays; the result hunk
supersedes it (ACP content-replace).

Mechanism:
- A persisted tool-private `meta` channel: execute may return
  `{ content, meta }`; `meta` (JsonValue) rides on the tool/result event
  and is handed back to presentResult, so the diff reproduces on replay
  (event-sourced). JsonValue is now exported from dsh-session.
- The backend returns raw before/after text (storage facts) on
  FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm
  `diff` package's structuredPatch. A create has no before → no result
  diff; a failed/aborted mutation carries no meta.
- ToolResultView gains a DiffResultView; the bridge's result-side switch
  renders it as {type:'diff'} content blocks.

RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
(justifies the npm `diff` runtime dep over vendoring and the meta channel);
the render-intent-union RFC's Non-goal is updated to record this shipped.
All fs snapshot goldens re-recorded; edit/overwrite gain the contextual
result diff, create/read/policy-reject unchanged in structure.
2026-07-03 17:12:00 +08:00
Tianyi Cui 1a57d67058 refactor(tools): tagged render-intent union for tool-call presentation
Replace the "bag of optional fields" tool-presentation types
(ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a
card-tagged discriminated union — the standing FIXME(tool-presentation).
A tool declares one render intent per call/result and the ACP bridge
switches on `card`:

  ToolCallView   = generic | terminal | diff
  ToolResultView = generic | terminal

The `diff` card is new: fs write/edit now emit an ACP {type:'diff'}
content block (an editor's inline diff), which the old shapes could not
express. The bridge also relativizes a file card's title against the
session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping
locations/diff paths raw, and derives the no-capability fenced console
fallback from a terminal result's output. read gains the window-in-title
(`Read foo.txt (5 - 8)`) and an always-set location line, matching the
reference adapter field-for-field.

Migrates all three producer families (tool-fs, tool-bash, tool-todo) and
the sole consumer (the ACP bridge) together — the source does not compile
piecewise. Adds snapshot coverage for the terminal _meta path (a new
capability-advertising scenario) and re-records the fs goldens to show the
diff cards. Applied-hunk (result-time, context-line) diffs need a new
result/event shape and are a follow-up.

RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
2026-07-03 02:04:03 +08:00
Tianyi Cui 490fe002a1 test(acp): snapshot the fs-policy rejection card
The fs-policy gate throws FS_NOT_OBSERVED when the model edits a file it
never read; that rejection surfaces as a failed tool_call_update, but no
snapshot pinned it — a regression that dropped or mis-rendered the failed
card would pass every gate. Record a scenario that edits a seeded file
without a preceding read: the edit is vetoed, the file stays unchanged on
disk, and the transcript shows the pending edit card followed by a
status:'failed' update carrying the policy error.
2026-07-02 23:41:02 +08:00
Tianyi Cui 94cbec8162 test(acp): snapshot scenarios for the filesystem tools
Five recorded ACP snapshot scenarios exercising read/write/edit end-to-end
through the real acp-agent subprocess, replayed keyless in CI:

- fs-read — read a seeded file (read tool + presentation + observed-state)
- fs-write — create a file (write, no prior version guard)
- fs-edit — read then literal-replace (read-before-edit authorization)
- fs-write-overwrite — read then rewrite (replaceIfVersion after a read)
- fs-read-window — read lines 5-8 with offset/limit (windowing + the offset
  surfaced as the tool_call location line)

The goldens confirm the tools render with their new presentation — Read/Write/
Edit <path> titles, read/edit kinds, and `locations` (fs-read-window carries
`{path, line:5}`) — and that the prompts steered the model to the fs tools, not
bash (zero bash calls in any golden). Recorded against the real API, filtered to
the new scenarios so no existing fixture churned.
2026-07-02 20:09:09 +08:00
Tianyi Cui 4fc0d4833c Merge branch 'worktree-hooks-e-protocol' into worktree-hooks-f-bridges 2026-07-02 19:01:13 +08:00
Tianyi Cui bae6141398 fix(hooks-claude): build subagent payloads from base(), run SubagentStop in the child cwd, drop agentType
Address the D agentType removal + two #124 review findings on the CC bridge's
subagent points:

- **Payloads from base()**: `subagentStart/StopPayload` bypassed `base()`, so the
  SubagentStart/SubagentStop stdin payloads omitted the CC-promised `session_id`
  and `cwd`. Replaced both with a single `subagentPayload()` built from `base(child)`
  (the child's session_id/cwd when the child is available) + `agent_id` +
  `agent_type` (+ `stop_hook_active` on Stop).
- **SubagentStop runs in the child cwd**: the listener called `runPoint(..., {})`
  with no agent, so the hook ran in the executor/server cwd. It now looks the
  child up via `ctx.get('agents').get(info.id)` — still recoverable because
  `subagent/end` fires from the service's detached `.then` BEFORE the tool caller
  disposes the child — and passes `{ agent: child }`, matching SubagentStart.
  New regression: server cwd ≠ child cwd, a `pwd` SubagentStop hook proves it
  ran in the CHILD workspace (proven red by neutering the lookup).
- **agent_type is a constant**: `info.agentType` no longer exists (removed on the
  subagent branch); both points now report the `SUBAGENT_TYPE = "general-purpose"`
  constant (Claude Code's Task-tool default), so a hooks.json default/`*`/empty
  `agent_type` matcher fires. Updated the README matcher-subject note and the
  bridge/coverage tests (dropped their agentType emits).
- **e2e comment**: hooks.e2e.ts said `./hooks.json` loads from the session cwd;
  corrected to process-level (server launch cwd), with the hook itself running
  in the session cwd.
2026-07-02 06:45:17 +08:00
Tianyi Cui 8adcbceeed feat(hooks): dsh-hooks-claude + dsh-hooks-codex bridges (hooks stack PR-F)
The two bridge plugins that run a user's existing Claude Code / Codex hook
config on the harness's typed interception seams, built on the shared
dsh-hook-protocol library. A bridge is a faithfulness adapter, not a power
tool: anything it does a native cordis plugin does more powerfully — the
bridge exists only to run UNMODIFIED external hooks.

- dsh-hooks-claude: CC dialect. Seven hook points (SessionStart,
  UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart,
  SubagentStop), CC per-event stdin payloads, env + ${CLAUDE_PLUGIN_ROOT}/
  ${CLAUDE_PROJECT_DIR} substitution, literal-or-regex matcher.
- dsh-hooks-codex: Codex dialect — a deliberate subset. Five hook points,
  always-regex matcher, snake_case payloads (turn_id/model, no trailing
  newline), no env/substitution, block-only decisions.

Both map the neutral merged outcome onto the seam's typed Decision and stamp
an explicit {kind:'plugin'} source on injected context (so it is never
mislabeled as a user prompt). Config parse-failure is contained; only command
hooks run. updatedInput is logged+warned (input rewrite deferred); the Stop
loop-guard is deferred (TODO).

Tests: per-file 100% — config-parse unit branches + per-seam mappings
end-to-end through the REAL loop + REAL bash + REAL shell scripts (scripted
mock model only) + a real-Loader export-shape guard. A keyless ACP snapshot
scenario (hook-prompt-block) proves a UserPromptSubmit hook blocks a prompt
end-to-end (rejected turn -> ACP cancelled, hook/* events in the log); a
with-key e2e (hooks.e2e.ts) proves a PreToolUse hook blocks real bash
(verified on disk). The snapshot normalizer now scrubs hook/result.durationMs.

RFC: docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
2026-07-01 04:23:49 +08:00
Tianyi Cui 93a6dc6716 test(todo): add the todo-plan ACP snapshot scenario and a with-key e2e
Record the `todo-plan` snapshot scenario: a real prompt drives the model to call
todo_write, and the golden captures the resulting `plan` sessionUpdate (three
entries, priority synthesized as medium, status 1:1) plus the persisted
todo/write event. Registered in SCENARIOS; replays deterministically keyless.

Add a with-key coding-agent e2e that verifies the WORLD — a real model call to
todo_write lands a todo/write event whose snapshot is a valid, one-in-progress
list — not the agent's self-report. Wire tool-todo into the e2e harness.
2026-06-29 10:56:55 +08:00
Hypatia May 9cc8dc371e Merge remote-tracking branch 'origin/master' into session-surface
Reconciles the session-surface work (surfaceOp/sourceEventSeqs provenance as
the sole derivation path) with master's worktree-subagent series (fork-seed
boundary + out-of-process subagent backends).

Semantic reconciliations beyond the textual auto-merge:
- SQLite SCHEMA_VERSION: both sides bumped 2->3. Merged to a single v3 carrying
  BOTH column families — master's seed_length on `sessions` and surface's
  source_event_seqs/surface_op on `events`. writeRow + both INSERT sites bind
  the full set; the schema doc lists all three added columns as the v2->v3 gap.
- agent-loop runStep request: master's `sessionId: session.id` and surface's
  per-append surfaceOp/sourceEventSeqs coexist (different regions).
- Fork seed + surface: a fork seeds the child from the parent's LIVE events,
  which now carry surfaceOp, so the child's surface rebuilds correctly. Verified
  end-to-end — the subagent-fork replay recalls the inherited "SAFFRON" codeword
  through the seeded prefix.
- Subagent snapshot fixtures (recorded pre-surface) re-enriched via KEYLESS
  deterministic replay: only surfaceOp/sourceEventSeqs added onto existing
  recorded lines (matched by seq), no recorded value changed. Not re-recorded
  against the live API.

Gates: typecheck, test (1112), test:snapshot (14), doc-sync, lint, build,
hygiene all green.
2026-06-24 10:48:01 +08:00
Tianyi Cui 78d60366ec Record fork and mixed spawn+fork snapshot scenarios
The seed-boundary change made fork-child replay route correctly but shipped
with no recorded fork scenario — the seedLength slice was exercised only by
llm-replay unit tests and a persistence round-trip, never by the full-transcript
snapshot tier. Add two recorded scenarios that drive a real fork child through
it:

- subagent-fork: parent completes a turn, then forks one child (child fixture
  carries a non-zero seedLength, the boundary the replay slice consumes).
- subagent-mixed: parent completes a turn, then delegates once via spawn
  (seedLength 0) and once via fork (non-zero seedLength) in one transcript —
  the first scenario to drive two subagent backends at once, exercising both
  branches of the slice.

Both need a completed turn-1 so the fork seed is a non-empty completed-turn
prefix (a turn-1 fork seeds empty = spawn, which would not exercise the slice).
Removing the slice turns both scenarios red (the fork child receives the
parent's recorded chunks), proving the guard bites.

ACP (out-of-process) subagent replay remains a different shape, still tracked
as TODO(acp-subagent-replay).
2026-06-22 21:20:54 +08:00
Hypatia May 0298f5c6f0 Merge remote-tracking branch 'origin/master' into session-surface
Reconcile the session-surface feature with master's package reorg and
simplifications:

- Adopt master's folded usage (assistant/message.usage; standalone `usage`
  event dropped) and re-attach surface metadata (surfaceOp/sourceEventSeqs).
- Add surface opts to master's new max-tokens assistant/message append.
- Port surface columns onto the coordinator-refactored SQLite backend at its
  new path; drop the dead v1->v2 migration (bump-and-reject, no migration per
  pre-release policy).
- Move the session-surface RFC into implemented/architecture/ and refresh its
  stale body (no migration, SESSION_FORMAT_VERSION=0, renamed package paths).
- Update the core-data-structures catalog SessionEvent blocks for the two new
  surface fields; regenerate the cordis catalog.
- Re-harvest ACP snapshot fixtures (keyless replay) to carry surface metadata.
2026-06-22 10:35:59 +08:00
Tianyi Cui 413db68008 Clarify the child-ordering invariant (Codex review follow-up)
The createdAt+recordedId child sort comment over-claimed "tie-safe". Codex
flagged that a same-millisecond sibling tie would be broken by random session
id, which does not recover first-call order. In the current synchronous cut that
tie is unreachable — the subagent tool awaits one child's result and disposes it
before the parent starts the next, so siblings' createdAt values are strictly
ordered and match first-call order. Restate the comment to that real invariant
(at both the replay sort and the harvest sort), note that the id tiebreak only
makes a degenerate collision deterministic, and flag the concurrent-subagent cut
that would need a real first-call ordinal with XXX(concurrent-subagents). The RFC
records the same limitation. Comment/doc only — no behavior change.
2026-06-22 09:01:09 +08:00
Tianyi Cui e68496fd79 Add per-session snapshot replay for nested agents (PR2.5)
The snapshot tier was built single-session: dsh-llm-replay served calls from
one global positional cursor, and the harness harvested one session log. A
subagent runs as a second agent with its own session, so a parent→child
scenario could neither replay deterministically nor harvest the child's log.
This resolves the TODO(subagent-snapshots) deferral from the subagent RFC.

- Stamp the calling session id onto the model request: GenerateOptions.sessionId
  (typed Branded<'SessionId'> to avoid the dsh-llm↔dsh-session cycle), set by the
  agent loop from agent.session.id. Adapters ignore it; an llm/stream listener
  routes by it.
- Key replay per session: dsh-llm-replay loads the parent log plus one per child
  (childFiles / $DSH_SNAPSHOT_CHILD_FILES), derives a script per recorded session,
  and binds each live (freshly-random) session to a recorded script by first-call
  order — parent first (earliest createdAt, first to stream). Keys by WHO calls,
  so it survives a future concurrent/backgrounded subagent; a global cursor would
  not. An unrecorded extra session fails loud.
- Harvest every log: the harness collects all .jsonl across cwd buckets, ordered
  primary-first (top-level, then children by createdAt), and RunResult exposes the
  plural sessionLogs. The spec writes each back on record (session.jsonl +
  session.<n>.jsonl) and diffs each against its fixture on replay.
- Wire the subagent seam + spawn + fork + tool into the acp-agent example (both
  cordis configs) and add two nested scenarios recorded against the real API:
  subagent-spawn (parent + 1 child) and subagent-multi (parent + 2 children, 3
  sessions). Both replay keyless in the default gate.

A new RFC documents the design (docs/rfc/implemented/testing/). Single-session
replay is unchanged (a call with no sessionId is one anonymous primary session).

TODO follow-up: a dedicated branded-ids package could own the SessionId brand and
dissolve the cross-package cycle note; out of scope for this testing PR.
2026-06-22 08:39:36 +08:00
Tianyi Cui 3567808171 fix review findings: harden the app bins + built-bin smokes, arch-exception doc, snapshot fixture-guard
BLOCKER — the published lib/bin.js (stdio + acp) was exercised only via tsx
(demo:* / the src/bin.ts smokes); the built artifact under plain `node` was
unguarded. Root-cause on the BUILT bin:
  1. Settle race: boot() returned once loader.create() registered the include
     ENTRY, but the include loads its child plugins asynchronously — so boot()
     (and main()) resolved while the app plugins (stdin reader, agent loop, ACP
     bridge) were still mounting. A CLI with no attached handles yet exits 0
     silently, and a load error surfaces as an unhandled rejection AFTER boot.
     Fix: `await ctx.loader.await()` after create() — settle the whole tree.
  2. Config-path robustness: hand the include the config's ABSOLUTE file:// URL
     so resolution never depends on ctx.baseUrl / can never fall back to cwd.
Both bins fixed identically. NOTE: the cordis Loader resolves a config's bare
plugin specifiers via its internal module loader, active only under
`node --expose-internals`; the bin cannot add a node flag itself, so this is
documented in the bin JSDoc + both package READMEs (the demos already comply).
The repo `examples/*/cordis.yml` are tsx-only artifacts (workspace plugins
resolve through the tsconfig paths map, not node_modules), so they are not a
valid plain-node bin target — the smokes use a real-install-shaped temp dir.

Fail loud on a load failure: boot() previously exited 0 SILENTLY when a config
path's directory does not exist — the include plugin fails to IMPORT, the cordis
Loader catches+LOGS it and leaves the entry with no fiber (no rejection), and
`loader.await()` does not rethrow (EntryTree.await uses Promise.allSettled). Fix:
boot() now calls assertEntriesLoaded(ctx) after the tree settles and throws on
any entry with no fiber, so a typo'd config dir exits non-zero with a clear
message. main() also installs an unhandledRejection guard (installFailLoud) that
replaces Node's stack dump with a single labelled stderr line for the
companion case (a missing config FILE in a real dir, whose include-init throw
surfaces as a rejection Node already exits non-zero on). Regression tests added
to both built-bin smokes (missing dir + missing file → non-zero exit + stderr);
verified the missing-dir test fails on the pre-fix bin.

Built-bin smokes (the reviewer's ask): packages/ui/{stdio,acp}-agent/tests/
built-bin.e2e.ts run the REAL lib/bin.js under `node` (NOT tsx) in a temp
consumer dir, asserting the stdio echo round-trip / the acp initialize response
+ stdout purity, plus the fail-loud cases above. They build-gate (skip if lib/
absent) and run in a new ci.yml step after the build.

Issue 2 — packages/README.md + docs/architecture.md said "plugins depend on
interfaces, never on the concrete loop", but dsh-agent-core imports the concrete
dsh-agent-loop. Scope the rule to EXTENSION plugins and carve out the sanctioned
COMPOSITION/bundle exception (dsh-agent-core composes the concrete spine); note
it in the implemented RFC too.

Issue 3 — examples/acp-agent/tests/acp.snapshot.ts fixture-guard claimed
no-model scenarios need no session.jsonl, but runScenario() always boots
llm-replay with the session.jsonl path and loadReplayScript() throws when it is
absent. Require session.jsonl for ALL scenarios (no-model ones ship a
header-only fixture) and rewrite the comment to match reality.
2026-06-21 15:39:24 +08:00
Tianyi Cui 47afcb297f Merge PR6 (snapshot golden removal + PR5 trace-events) into PR7 2026-06-21 12:10:49 +08:00
Tianyi Cui e2bde2902c refactor(examples): extract the app spine into dsh-agent-core + app packages
Implements docs/rfc/.../2026-06-20-extract-example-app-packages.md. Each
example was thick — a hand-rolled start.ts, an infra preamble, nested
base.yml/base-core.yml/acp-tail.yml includes, and a coupled front-door
cluster enforced only by prose. This moves the composition into packages so
each example is a thin leaf cordis.yml: pick the swappable backends, load one
app package.

New packages:
  - @deepseek-ai/dsh-agent-core (packages/core/agent-core): one bundle plugin
    that loads the providerless/executor-less/UI-less spine (timer + llm +
    sessions + system-prompt + tools + agents + invariants + tool-bash +
    agent-loop) via ctx.plugin(...) inside apply(), and forwards agent-loop's
    `agents` list as its own Config (export const Config = AgentLoop.Config,
    default []).
  - @deepseek-ai/dsh-stdio-agent (packages/ui/stdio-agent): terminal chat APP —
    agent-core + console logger + readline UI + a pre-created `main` agent, with
    a bin. The demo:echo/coding front door.
  - @deepseek-ai/dsh-acp-agent (packages/ui/acp-agent): ACP server APP —
    agent-core + JSONL persistence + the acp bridge, NO stdout logger, with a
    bin. The stdout-purity footgun is structurally unreachable from the leaf.

Amendment to the RFC: hmr stays a LEAF cordis.yml entry, not baked into
dsh-stdio-agent. hmr is a Loader-only dev plugin (throws without
--expose-internals; the in-process test tier can't even import its decorator
form), so a package statically importing it could never carry the per-file
coverage gate. Unlike the console logger, a stray hmr is not a stdout-purity
footgun, so leaving it at the leaf costs no safety. With hmr out, all three new
packages carry in-process unit specs at 100%.

Boot glue (Loader tail, .env load, snapshot-mode selection, stdin-dispose
lifecycle) moves into each app's bin; start.ts and base.yml/base-core.yml/
acp-tail.yml are deleted. Each app package gets a keyless real-load-path test
that boots through its bin + the cordis Loader (guarding the unwrapExports
export-shape bug class, postmortem 0001). ACP snapshot replay stays green
against the existing committed goldens (pure boot restructuring). RFC moved
proposed->implemented with the amendment recorded; package/example/architecture
docs and the module graph updated.
2026-06-21 12:06:38 +08:00
Tianyi Cui 655206848d Merge PR5 (trace-event collapse + v0 format pin) into PR6
# Conflicts:
#	examples/acp-agent/tests/snapshots/cancel/session.golden.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.golden.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.golden.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.golden.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.golden.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.golden.jsonl
2026-06-21 11:12:48 +08:00
Tianyi Cui b0422f2a50 fix review findings: bump session format version + restore late turn-end warn
Codex review of the trace-event fold found two merge-blockers.

Blocker #1 — format version. Folding usage onto assistant/message and removing
the standalone usage/error events changed the persisted SessionEventMap shape,
which per the AGENTS.md "bump the version and reject — don't migrate" policy
requires a backend to reject any non-current log. Centralize the version in an
exported SESSION_FORMAT_VERSION constant (dsh-session), read by both write sites
(Session constructor default, SessionStore.prepare header) and the coordinator's
load-time assertVersion check. The constant is pinned at 0: while unreleased the
on-disk format is unstable/pre-release, so breaking shape churn is absorbed at v0
(no monotonic bump until the first tagged release) and any non-0 log is rejected
on load — no migration. Update every test/fixture/doc that stamps a
currently-written header to the constant, bump the ACP snapshot fixture + golden
headers to v0, and keep the version-rejection test meaningful by switching its
bad value to a clearly non-current 99. AGENTS.md documents both the monotonic
(SQLite SCHEMA_VERSION) and pinned-0 (session log) pre-release stances.

Blocker #2 — restore the late turn-end warn. failTurn now sets the error reason
only while the turn is still open; once turn/end is appended (a throwing
agent/turn-end listener after closeTurn) the reason can no longer reach the
durable log, so the late throw is logged via ctx.logger.warn instead of
vanishing into a futile post-close assignment. A regression test asserts the
warn fires.

Also guard the normal-step assistant/message append with the same
content-or-usage condition as the max-tokens branch (a content-less, usage-less
step records no trace-only row), with a covering test.
2026-06-21 11:08:10 +08:00
Tianyi Cui 4209e4af3f test(snapshot): use session.jsonl as the only session-log artifact (drop session.golden.jsonl)
Model-driving ACP snapshot scenarios shipped both session.jsonl (the
replay fixture) and session.golden.jsonl (the expected re-persisted log).
For recorded scenarios the normalized fixture and golden were byte-identical
— pure duplication. Remove session.golden.jsonl entirely: every model
scenario now has at most one committed session-log artifact, session.jsonl,
which doubles as the replay source AND the expected produced log.

The snapshot test compares the replay run's persisted log against the
session.jsonl fixture, normalizing BOTH sides — but each against its OWN
volatile values, not a shared context. A raw harvested fixture bakes in the
recording run's session id / cwd / timestamps, distinct from the live replay
run's; since normalizeSessionLog scrubs cwd by exact string match, the
fixture must be normalized against its own header (new fixtureContext helper)
or its stale recorded cwd would leak unscrubbed and the compare would fail.
The session side uses a normalized-string toEqual, NOT toMatchFileSnapshot,
so a run never overwrites the fixture.

Authored override scenarios (error-finish, cancel) now hold their expected
produced log in session.jsonl. Verified llm-replay ignores the fixture for
model chunks when an override exists: loadReplayScript() returns the override
array and never reads config.file, so committing the full expected log there
does not affect replay behavior.

The required-fixture guard is now per-kind: every scenario needs input.json +
stdout.golden.jsonl; model scenarios need session.jsonl; authored ones
additionally need replay.override.json. Updates the ACP-snapshot-tests RFC to
the reduced fixture set and moves the proposing RFC proposed -> implemented.
2026-06-21 10:36:57 +08:00
Tianyi Cui 2be60b9a22 simplify(session): fold trace-only usage/error events into load-bearing events
The session event vocabulary carried two standalone trace-only events that
were not load-bearing as separate records. Fold their facts into nearby
load-bearing events and delete the standalone variants.

- Token usage now rides on `assistant/message` as an optional `usage` field —
  the assembled model output and its accounting travel together. The loop folds
  `assembler.usage` onto the append instead of emitting a separate `usage`
  event.
- The max-tokens path is the no-data-loss host: a step cut off with usage but
  EMPTY content (e.g. only a dropped tool call) previously emitted a standalone
  `usage`; it now records an empty-content `assistant/message { content: [],
  usage }`. `deriveMessages()` skips empty-content assistant messages, so the
  usage host never injects a spurious content-less assistant turn into the
  provider transcript. A step with neither content nor usage appends nothing.
- An operational error's step number now rides on `turn/end.reason` for
  `kind: 'error'` (`{ kind: 'error', step, message, code? }`) — the durable
  turn outcome ACP and resume already consume. `failTurn` sets the reason
  directly (no separate session `error` event). `agent/error` + logging are
  unchanged for live diagnostics.
- No format-version bump: pre-release, no persisted data, so per the format
  policy there is nothing to migrate or reject (the RFC's "refresh the format
  version" criterion over-reached). `version` stays 1.
- ACP fixtures + goldens re-recorded (keyless replay): dropped standalone
  usage/error lines, usage folded onto assistant/message, error step on
  turn/end.reason.

RFC moved proposed -> implemented with an implementation note recording the two
scope refinements.
2026-06-21 10:00:06 +08:00
Tianyi Cui 605587e79c docs(rfc): classify RFCs by kind via path-encoded subdirectories
Add a second axis to every RFC — its class (feature, bug-fix,
simplification, architecture, process, testing) — encoded in the path
as docs/rfc/{lifecycle}/{class}/file.md. The folder is the label, so
the closed set is enforced by structure rather than a parsed field.

Two new doc-sync gates back it:
- verify-rfc-classification: every RFC sits in a valid class folder and
  the README index lists it under the matching lifecycle→class heading.
- verify-doc-refs: every docs/*.md path cited in a packages|examples TS
  comment resolves — closes a drift class verify-md-links can't see, and
  catches the four comment refs this reorg moved.

The README gains a Classification section explaining the taxonomy and
per-class index sub-sections. A self-referential process RFC records why
the scheme is path-encoded and gated.
2026-06-20 22:29:45 +08:00
Tianyi Cui 072f97c184 refactor(examples): extract reusable logic into tested packages
Logic that lived under examples/ was outside the per-file 100% coverage
gate (examples/ are not workspaces) and, in the stdio-UI case, duplicated
across two examples. Move it into packages/ so it is gated and de-duped.

- packages/ui-stdio (new): unify the two diverged stdio-chat.ts copies into
  one @deepseek-ai/dsh-ui-stdio plugin (welcome/agent Config). A test-only
  I/O seam (createStdioChat(ctx, config, runtime)) keeps process streams out
  of the serializable config and makes every render/EOF/disposal branch
  unit-testable. Per-file 100%. echo/coding cordis.yml now load the package;
  both src/stdio-chat.ts deleted.
- packages/llm-replay (new): move examples/acp-agent/src/llm-replay.ts (+ its
  spec) here so its derive/parse/replay branches fall under the coverage gate.
  cordis.snapshot.yml + README rewired to the package name; added apply/env
  /assertNever/abort tests to reach per-file 100%.
- examples/{echo,coding}-agent: keyless Loader-path e2e smokes that boot the
  real cordis.yml (no key) — the guard a hand-mounted unit test cannot be for
  the unwrapExports/export-shape class (postmortem 0001). examples/AGENTS.md
  codifies the keyless+with-key smoke convention (keyless-by-nature exception
  for echo-agent).
- AGENTS.md: a scoped, removal-triggered pre-release stance (foundation over
  blast radius). packages/README.md: new rows + a FIXME to later regroup ALL
  packages into a hierarchy. Wiring: tsconfig paths/refs, publint, knip,
  module-graph.

Verified: typecheck, lint, test:coverage (887 tests, 100%), build, hygiene,
doc-sync, test:snapshot (10), test:e2e (6 keyless pass, with-key self-skip).
2026-06-19 12:42:28 +08:00
Tianyi Cui 6d02059f9f test(acp-example): snapshot the session/new workspace-scope rejection
Master's "fix(acp): align prompt and workspace contracts" made session/new
reject a non-empty additionalDirectories / mcpServers (widening the workspace
scope is unimplemented). Add a `reject-extra-dirs` scenario + a
`newSessionExpectError` input op that pins this editor-facing contract: the
bridge answers with `-32602 Invalid params: additionalDirectories is not
supported`. Keyless, deterministic, no model call.

(session/load replay — the other new master behavior — needs a two-phase
seed-then-load harness and is left for a focused follow-up.)
2026-06-19 10:10:32 +08:00
Tianyi Cui 72df4d2e16 test(acp-example): re-record snapshot goldens after merging master
Master's "fix(acp): align prompt and workspace contracts" changed the editor-
facing transcript — `user/message` → `user_message_chunk` is now emitted during
session/load replay ONLY, not live streaming, so a live prompt no longer echoes
the user message back. The snapshot tier caught this (6 goldens shifted); this
re-records the four recorded scenarios against the API and re-accepts the two
authored goldens so they reflect the merged behavior. Full suite green and
deterministic; this is the tier working as designed.
2026-06-19 10:07:06 +08:00
Tianyi Cui 76fceae5b3 feat(acp-example): per-scenario workspace/ seeding + a real file-edit scenario
Establishes the standard way to give a snapshot scenario a non-empty starting
workspace: an optional `<scenario>/workspace/` directory whose contents the
harness copies into the temp cwd before the run (for both record and replay),
so the agent's bash tools see the seeded files. The cwd is normalized in the
goldens, so seeded paths stay stable.

The new `workspace-edit` scenario demonstrates the full read→write→verify cycle
on a seeded file: it ships `workspace/greeting.txt` ("hello"), prompts the agent
to append a WORLD line and cat it back. The recorded log captures the real bash
edits (`echo WORLD >> greeting.txt`, then `cat` showing `hello\nWORLD`), and it
replays deterministically with no key.

Also hardens runScenario teardown (Codex review): workspace seeding and spawn
now run inside the try whose finally removes both temp dirs, so a seeding/spawn
failure can't leak them. Documents the convention in the RFC + example README.
2026-06-19 10:01:42 +08:00
Tianyi Cui ec73e11c9b refactor(acp-example): snapshot goldens are JSONL, not pretty-printed .txt
The goldens now mirror the shape of the surfaces they capture — one compact
JSON record per line — matching the wire (NDJSON stdout) and disk (JSONL
session log) formats, renamed *.golden.jsonl. They stay grep/jq-able and
faithful to what the agent emits, where the prior pretty-printed .txt was a
reformatted representation. Both normalizers drop the 2-space indent; the
normalizer spec asserts the compact form. All 11 goldens regenerated; replay
remains deterministic (8/8 across runs).
2026-06-19 09:29:41 +08:00
Tianyi Cui c94f1563f5 test(acp-example): five snapshot scenarios + cancel/error input ops
Adds the first cut of snapshot scenarios, each asserting a normalized stdout
transcript golden and (for model turns) a re-persisted session-log golden:

- text-turn, tool-call-turn, multi-turn: RECORDED against the real API — the
  committed session.jsonl is a genuine harvested log; replay derives the model
  script from it and reproduces deterministically with no key. tool-call-turn
  exercises the real bash executor (echo SNAPSHOT_OK → tool/call + tool/result
  + a post-tool answer step).
- error-finish, cancel: AUTHORED via a replay.override.json sidecar (the live
  API can't be coaxed into a deterministic 401 or mid-stream cancel). error-
  finish replays a {kind:throw} 401 → the bridge answers the prompt with a
  JSON-RPC error and the log records turn/end{kind:error}; cancel replays a
  {kind:hang} → stopReason:cancelled.

Two input-DSL ops support these: promptExpectError (awaits the prompt, asserts
it rejects — the editor's view of a failed turn — and swallows it) and
promptAndCancel (dispatches the prompt unawaited, waits until the client
OBSERVES the streamed agent_message_chunk, then cancels — pinning frame order
so the cancel transcript is deterministic; fixes a flake Codex caught where the
late chunk and the cancelled response could interleave either way).

Scenarios carry a `recorded` flag so test:snapshot:record only re-runs the
live-API ones. reasoning/max-tokens scenarios are deferred (hard to force
deterministically from the live model). Per docs/rfc/implemented/2026-06-19.
2026-06-19 04:10:49 +08:00
Tianyi Cui 81d434896d feat(acp-example): snapshot harness, normalizers, wiring, and handshake scenario
Adds the snapshot-test harness and the keyless replay pipeline end-to-end.

- snapshot-harness.ts: boots the real acp-agent subprocess via the cordis
  Loader (preserving TSX_TSCONFIG_PATH so unbuilt dsh-* imports resolve from a
  temp cwd), tees raw stdout into an SDK ClientSideConnection, interprets a
  per-scenario input.json DSL (initialize / newSession capturing the random
  sessionId / prompt / cancel), closes stdin to trigger graceful shutdown, and
  harvests the persisted session.jsonl. Failure-safe: a finally block SIGKILLs
  a live child, awaits its exit, and removes both temp dirs even on a thrown
  step or harvest. Raw bytes are buffered and decoded once (no multibyte split).
- snapshot-normalize.ts (+ spec): two pure normalizers (stdout frames + session
  JSONL) scrub cwd, session ids / UUIDs, and JSON-RPC ids, and zero time /
  createdAt — but keep `seq` (deterministic by contract). normalizeStdout throws
  on a non-JSON line (the stdout-purity check).
- start.ts: selects cordis.snapshot.yml (replay, providerless) or
  cordis.snapshot-record.yml (record, real adapter) from DSH_SNAPSHOT, skips
  .env in replay, and disposes the ctx on stdin end so persistence flushes
  before exit (harvest-after-flush, not on the prompt response).
- acp.snapshot.ts: asserts the normalized stdout golden (and, for model
  scenarios, the re-persisted JSONL golden) via toMatchFileSnapshot; record mode
  writes the harvested log back to the scenario fixture; an orphan-fixture guard
  fails on an unregistered scenario dir.
- handshake scenario: initialize + session/new (no model call; a header-only
  session.jsonl, since session/new persists no events).
- vitest.snapshot.config.ts, test:snapshot / test:snapshot:record scripts, a
  pre-push snapshot job, and the knip entry.

Incorporates Codex review: record-fixture writeback, failure-safe teardown,
seq-not-scrubbed, harvest-after-flush. Per docs/rfc/implemented/2026-06-19.
2026-06-19 03:36:12 +08:00
Tianyi Cui c182543dd5 refactor(acp-example): derive llm-replay script from the session JSONL
Per a design revision, the per-scenario snapshot fixture becomes EXACTLY the
persisted session JSONL (<scenario>/session.jsonl) rather than a hand-authored
llm.json. The log already holds all LLM behavior (assistant/chunk carries every
StreamChunk) AND all harness behavior (tool/call, tool/result, turn/*, usage),
so one artifact drives replay and doubles as a behavioral golden.

llm-replay becomes replay-only (the record-tee is removed; recording is now
"run the real agent once and harvest the .jsonl", done by the harness in a
later commit). deriveReplayScript(events) groups assistant/chunk by (turn,step)
in log order — exact because the loop makes one ctx.llm.stream() call per step
and tags each chunk with the current (turn,step). The two failure modes the log
can't express (a thrown stream — no terminal finish; cancel/hang — timing) use
an optional replay.override.json sidecar.

Hardens against a Codex review finding: a derived group is only valid if it
ends in a `finish` chunk. A group without one is the fingerprint of a thrown
stream() and is NOT silently replayed as a clean stop — deriveReplayScript
throws, naming the (turn,step), so a missing sidecar override fails loud.

Updates the unit tests (parse/derive/load helpers, sidecar override, finish-
terminated grouping, HMR), the example README, and the RFC prose to the JSONL
format. Two goldens (stdout transcript + re-persisted JSONL) and the harness
wiring land in the next commit.
2026-06-19 02:44:33 +08:00
Tianyi Cui 1a1ce734ba feat(acp-example): add record/replay llm/stream plugin for snapshot tests
Introduces examples/acp-agent/src/llm-replay.ts, a function/namespace plugin
that installs a single llm/stream waterfall listener. In record mode it tees
the real model's StreamChunks into a per-scenario llm.json (flushed atomically
after EACH stream, since the snapshot subprocess is SIGKILLed and start.ts has
no disposal path). In replay mode it short-circuits the waterfall and serves
recorded streams back positionally — the Nth stream() call gets the Nth entry —
so a snapshot test can drive the real agent with no API key.

Each fixture entry is a discriminated record {chunks|throw|hang} so it can
replay BOTH branches of the LLM failure contract (throw from stream() vs a
finish-error chunk) plus cancellation. A throw entry carries the prefix chunks
emitted before the throw, replayed before the error, so a mid-stream failure
(partial output then STREAM_CLOSED) reproduces what the loop saw live.

Fail-loud on a missing or exhausted fixture (never a silent skip). Unit tests
drive the real LlmService waterfall (record tee, ordered replay, the three
entry kinds, partial-then-throw, fail-loud, event-driven abort, HMR-safety).
Broadens the unit vitest include to examples/*/tests and registers the plugin
+ snapshot tests as knip entries. Per docs/rfc/implemented/2026-06-19.
2026-06-19 01:10:30 +08:00
Tianyi Cui e51dabbb8b feat(acp): align bash terminal card with reference adapters (command title, description block, exit pill)
Match claude-agent-acp / codex-acp: the bash tool_call title IS the command
(an execute card hides rawInput), the model description rides as a content
text block above the card, and the completed card carries an exit-status pill
via _meta.terminal_exit.

Bridge fixes found in review of the prior terminal-card commit:
- tool_call_update.content is OMITTED in terminal mode (an ACP update.content
  REPLACES the call's content collection in Zed, so the fenced ```console block
  would clobber the terminal content block).
- terminal.output preserves RAW newlines (terminal renderers rely on exact
  bytes); only the fenced fallback trims trailing blank lines.
- a relative workdir is resolved against the session cwd for the card header,
  matching where the command actually ran.
- result-side terminal output is gated on the pending call having registered a
  terminal (no orphan _meta.terminal_output for a terminal Zed never made).

The exit pill is recovered by parsing renderResult's status markers (the pure
presentResult seam sees only content blocks); a round-trip test pins the parse
to the marker emission. Neutral ToolTerminal gains exitCode/signal; widened
ToolCallPresentation with a content block. Docs (RFC + 3 READMEs) updated;
with-key e2e verifies the card + exit pill against the real model.
2026-06-18 18:54:32 +08:00
Tianyi Cui 149ab1bba4 feat(acp): render bash as a terminal card via the _meta convention
When the client advertises clientCapabilities._meta.terminal_output (Zed), a
bash tool call now renders as a real TERMINAL card — a cwd header + the command
+ its output — instead of the plain ```console text block. Keeps agent-side
dsh-bash execution; rejects the spec's client-side terminal/create (which would
bypass sandbox/env-scrub/ownership/cwd). Matches what claude-agent-acp and
codex-acp do; wire contract verified against Zed's source.

- dsh-tools: a provider-neutral ToolTerminal shape ({ cwd?, output? }) on
  ToolCallPresentation/ToolResultPresentation — a tool asks "render me as a
  terminal"; no ACP types leak in.
- dsh-tool-bash: bash presentCall marks terminal (cwd from an explicit absolute
  workdir, else left for the bridge to fill from the session cwd); presentResult
  carries the output alongside the ```console fallback.
- dsh-acp: initialize reads/remembers the _meta.terminal_output capability;
  streamSessionEventUpdate maps a terminal presentation to
  content:[{type:'terminal',terminalId}] + _meta.terminal_info on the call and
  _meta.terminal_output on the update WHEN capable — else the unchanged text
  path. terminalId is the callId; cwd defaults to the session header. The pure
  translator gained a TerminalRendering {enabled,cwd} param (off by default).

Tests via the REAL tool-bash + bash-local: capability ON -> terminal content +
_meta; OFF -> no _meta (text path). The with-key e2e adds a real-model terminal
card case (echo over ACP with the capability on). 773 tests, 100% coverage.

The exit-status pill (_meta.terminal_exit), live streaming
(_meta.terminal_output_delta), and command classification are RFC follow-ups.
2026-06-18 17:25:09 +08:00
Tianyi Cui 7803c38824 feat(acp): tool-owned tool-call UI presentation (title/command/output)
In Zed the tool-call card showed only "bash" — the bare tool name — instead
of what the command does. Fix it by letting each TOOL own how its calls render,
rather than the bridge special-casing names.

dsh-tools: add an optional two-state presentation seam to ToolDefinition /
defineTool — `presentCall(args)` (pending: title, kind, rawInput) and
`presentResult(args, result)` (completed: title?, content?). Provider-neutral
`ToolCallKind`/`ToolCallPresentation`/`ToolResultPresentation` vocabulary so
tools never depend on ACP. defineTool soft-validates args (display runs on log
replay, so a malformed/old shape returns undefined instead of throwing).

dsh-tool-bash: bash declares presentCall (model `description` → title, exact
`command` → rawInput, kind execute) and presentResult (wrap output in a fenced
```console block — a UI-only affordance kept out of the model-facing result);
bash_output/bash_kill present task-scoped titles.

dsh-acp: inject `tools`; a per-session `ToolPresenter` looks the tool up by name
and maps its neutral presentation to the ACP tool_call/tool_call_update wire
shape, with a generic fallback (title = name) for tools that declare nothing.
Because the `tool/result` event carries only {callId, content, isError}, the
presenter keeps a small bridge-local map of ONLY in-flight calls' (name, args),
keyed by callId and removed as each result is presented — no event-schema or
core change. Replay uses a throwaway presenter so loaded sessions render
identically to live ones.

Tests: dsh-tools defineTool presenters (typed args, soft-validate), tool-bash
bash/bash_output/bash_kill presenters, acp ToolPresenter (tool-owned mapping,
unknown-callId fallback, in-flight-only map), and an end-to-end turn through the
bridge. The key-gated e2e now asserts a real bash call's title is the model
description (not "bash") and rawInput is the command — verified against the real
DeepSeek model. The test harness derives its inject from the bridge's exported
`inject` so it can't drift again.
2026-06-18 09:01:36 +08:00
Tianyi Cui 49bec650b8 Merge branch 'feat/acp-3-multi-session' into feat/acp-4-session-cwd
# Conflicts:
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	packages/acp/src/index.ts
2026-06-18 04:07:55 +08:00
Tianyi Cui 6d37b6c33d fix(acp): server crashed on connect — drop export default, read optional service cwd-independently
Two independent bugs made the ACP server crash the moment an editor (Zed)
connected, despite 178 green unit tests at 100% coverage:

1. `session/new` threw `cannot get property "agents" without inject`. Root
   cause: a stray `export default apply` made the cordis Loader's
   `unwrapExports` (`exports.default ?? exports`) collapse the module to the
   bare `apply` function, discarding the sibling `inject`/`name`/`Config`
   named exports. The plugin fiber was built with empty `inject`, so every
   `ctx.<service>` read in `apply` threw at load. Fix: remove the default
   export so the Loader uses the namespace.

2. `session/load` threw `cannot get property "sessionPersistence" without
   inject`. `AgentLoop.resume` read `this.ctx.sessionPersistence` (a service
   it deliberately does NOT inject); the property proxy's ancestor-only fiber
   walk fails through the bridge's traceable shadow. Fix: read it via
   `this.ctx.get('sessionPersistence', false)`, the topology-independent
   global-store lookup.

Why the suite missed both: every test mounted the plugin by hand
(`ctx.plugin({name,inject,apply})`), bypassing `unwrapExports` entirely, and
the only test driving these RPCs was key-gated (skipped in CI). Added a no-key
`session/new` e2e that boots the real example through the real Loader — it
fails loudly on bug #1 without an API key. Set `TSX_TSCONFIG_PATH` in the e2e
spawn so the subprocess resolves workspace `paths` from a temp cwd (it was
silently falling back to a stale built `lib/`).

Docs: post-mortem 0001; AGENTS.md "line coverage is not behavior coverage" +
with-key/smoke-test philosophy; packages/AGENTS.md plugin-export-shape and
ctx.get rules; dsh-code-review SKILL checks.
2026-06-18 03:12:37 +08:00
Tianyi Cui f3906af225 feat(acp): honor per-session cwd — run each ACP session in its own workspace
Lifts the RFC 010 § Deferred restriction that the server had to launch in the
workspace ("cwd must equal the launch directory"). An editor can now open any
project folder, and N concurrent sessions over one connection can each target a
different directory.

- packages/acp: drop the `cwd === process.cwd()` guard in validateWorkspaceParams
  (keep "must be absolute" — the cwd becomes the session header / bash workdir),
  and drop the persisted-cwd-vs-launch-dir check in session/load (a resumed
  session keeps its original header.cwd, so its bash tools run in its workspace).
- packages/tool-bash: the missing link — default the bash workdir to the calling
  agent's session cwd (`exec.agent.session.header.cwd`) via a new resolveWorkdir
  helper. An explicit model `workdir` still wins; a relative one resolves against
  the session cwd. This is the only correct spot for multi-session: N sessions
  share one ctx.bash executor, so the workdir must come per-call from exec.agent,
  not executor config. Falls back to the executor default when no session cwd is
  available (preserves non-ACP behavior).
- Trust: the cwd originates from the ACP client (the user's editor) at
  session/new — same trust level as the old launch dir; no new untrusted-input
  path. `additionalDirectories` (scope widening / sandbox) stays rejected.
- Tests: bridge accepts any absolute cwd + records it on the header; session/load
  honors the persisted cwd; bash defaults to / resolves relative against the
  session cwd; two sessions with different cwds each run bash in their own dir;
  non-absolute cwd still rejected. 100% per-file coverage maintained.
- Docs: RFC 010 status + § Deferred cwd bullet marked RESOLVED; acp README adds a
  Per-session cwd section; tool-bash + example READMEs and e2e comments updated.
2026-06-17 10:53:40 +08:00
Tianyi Cui fb9636db44 feat(acp): ACP bridge — drive the coding agent from an editor over JSON-RPC stdio
Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the
harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited
stdio), so Zed and other ACP editors can drive the coding agent — streaming
render, tool-call display, and resumable sessions via `session/load`.

- packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/
  prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a
  fallback chain (agent/turn-end → logged turn/end → idle); single-session
  guard; cwd-must-equal-launch-dir validation; load replays from the persisted
  event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*).
- agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent
  implements it (resolves on the first running→idle/disposed transition). The
  bridge awaits it on disposal so teardown reaches quiescence, not just abort.
- examples: extract the shared provider/tool core into examples/base.yml;
  coding-agent nest-includes it; new examples/acp-agent serves the agent over
  ACP with JSONL persistence and no stdout logger (stdout is the protocol).
- Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the
  executor's full authority; only the Agent→sessionId ownership seam is laid
  down. Cancel is best-effort for a not-yet-started queued turn
  (TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`.
- Docs: package README + Zed snippet; client-driver cookbook section; root and
  packages layout/commands; RFC 010 implementation-status note.

48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the
example as a subprocess and verifies a written file on disk (key-gated, with a
no-key stdout-purity check).
2026-06-16 18:44:31 +08:00