The loop recorded every model token delta as a durable `assistant/chunk`
session event AND emitted an identical live `agent/stream-chunk` Cordis event
one line later. Same StreamChunk, same turn/step; the emit added only the live
Agent handle, which the sole consumer discarded. This is the boundary-mirror
duplication the event-domain work removed for turn/step boundaries, applied to
the token stream — a follow-up the boundary RFC explicitly deferred.
The premise is settled: chunk persistence is authoritative (the proposal to
stop persisting chunks was rejected — replay/snapshots depend on it), so
`assistant/chunk` on `session/event` is the load-bearing token stream and
`agent/stream-chunk` is pure redundancy.
- Remove the `agent/stream-chunk` declaration + emit; drop the now-unused
StreamChunk import from dsh-agent's types.
- Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads
assistant/chunk off session/event) to render assistant/chunk in its existing
session/event listener. Consolidating to one listener also makes the
inReasoning dim-SGR flag deterministic across chunk/boundary events (they no
longer race across two listeners).
- Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the
session/event assistant/chunk feed.
- New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror);
amend the boundary RFC's retained-list entry to cross-link; update
architecture, cookbook, event-domain-semantics, the ACP proposal, and the
regenerated cordis catalog.
Snapshot goldens unchanged (ACP never used the mirror), confirming no
editor-facing transcript change.
Codex review of the interception-seams merge flagged current-state prose still
describing removed/renamed surfaces:
- packages/core/agent/src/types.ts module doc: the agent/* "transient emits"
list still ended with "the turn boundaries" — corrected to state turn/step
boundaries are durable session/event records, not agent/* emits, and to list
the actual interception seams (prompt-submit/pre-step/request/step-result/
turn-continuation) + agent/session-start.
- interception-seams RFC: "agent/turn-end fires and the ACP bridge settles" →
the durable turn/end is appended and ACP settles off it (no turn mirror).
- two proposed RFCs (acp-agent-client-protocol, optional-code-mode) named the
pre-split `tools/execute` waterfall → the `tools/pre-execute`/`tools/post-execute`
pair.
Regenerated the cordis catalog (module-doc change). No code/behavior change.
Bring the interception-seams branch onto current master (via A→B). The
substantive reconciliation is master's compaction `agent/pre-step` serial seam
meeting C's interception seams:
- types.ts: keep BOTH master's `agent/pre-step` AND C's new interception events
(`agent/prompt-submit`, `agent/session-start`, `agent/turn-continuation`→
`ContinuationDecision`); drop the turn-mirror declarations (removed on A).
- loop.ts: the merged per-turn order is `turn/start` → per queued msg
`agent/prompt-submit` (rewrite/inject/block) → (fully-blocked ⇒ zero-step
`rejected`) → per step: drain steering → assemble system prompt →
`agent/pre-step` (compaction, OUTSIDE the step) → `step/start` → single
`deriveMessages()` → model → tools/pre-execute·dispatch·post-execute. No
turn-mirror emits; `closeTurn()` is the A-simplified single-call form.
- Docs (architecture, core.md, agent/agent-loop READMEs, catalog) reconciled to
show C's interception seams alongside `agent/pre-step`, no turn/step mirrors.
- rfc/README: dropped the stale `proposed/` compaction row (master moved that RFC
to implemented/); kept C's new `pre-tool-input-rewrite` proposed row.
- interception.spec.ts: migrated its two `agent/turn-end` reason collectors to
the `turn/end` session event, and ADDED a cross-test proving a
`prompt-submit` rewrite + additionalContext is VISIBLE to an `agent/pre-step`
listener on the same turn — pinning the merged seam ordering (compaction sees
the post-prompt-submit surface, not stale history).
Codex review of the turn-mirror removal found current-state docs/comments that
still claimed the removed `agent/turn-start`/`agent/turn-end` events exist:
- docs/architecture.md: the loop diagram's turn-start line still said "emit
agent/turn-start" (the turn-end line was already fixed).
- event-domain-semantics RFC: the `agent/*` domain description listed "the turn
boundaries" among the transient emits.
- docs/core-data-structures/core.md: the agent/* taxonomy blurb listed
"turn/step boundaries" as agent events.
- the proposed ACP RFC: the settle-signal rows named agent/turn-start /
agent/turn-end; retargeted to the durable `turn/end` session event + the
session/event owning-turn correlation.
- loop.ts outer-catch comment: said "closeTurn/failTurn are idempotent" — after
the emit-param removal closeTurn is called exactly once (mutually exclusive
normal/catch paths), so corrected to state that and to scope idempotency to
closeStep (which is still guarded by stepOpen).
Regenerated the cordis catalog. No behavior change.
Complete the boundary-mirror removal begun with the step mirrors: drop
`agent/turn-start` and `agent/turn-end` from the agent event taxonomy. Turn and
step boundaries are now read exclusively off the durable `session/event` feed
(`turn/start`/`turn/end`/`step/start`/`step/end`) — there is no `agent/*` mirror
for any boundary.
- loop.ts: delete both turn emits; `closeTurn` loses its `emit` parameter and
its now-unreachable idempotency guard (it is called exactly once per turn, on
mutually exclusive normal/catch paths); `failTurn` loses the dead post-close
branch that only a throwing turn-end LISTENER could reach.
- ui-stdio: render turn boundaries from `session/event`, recovering the short
agent label from an `agent/created`→id map (the `turn/start` event carries only
the turn number, and the session id is not reliably the agent id). ui-stdio is
a disposable test REPL, so this migration retires the sole justification the
event-domain-semantics RFC gave for KEEPING the turn mirrors.
- Tests: reason/turn-number collectors and the boundary-ordering test now read
`session/event`; the throwing-turn-boundary-LISTENER tests are deleted (that
code path no longer exists). A new test covers the outer-catch disposed branch
via a pre-step listener that disposes-then-throws (the surviving real path).
- Docs: promote the "remove agent boundary mirror events" RFC to implemented
(amended/narrowed — `agent/steering` is RETAINED, not a boundary mirror);
update the event-domain-semantics + turn-enclosure RFCs, architecture.md, the
cookbook, the ACP/agent/ui-stdio prose, and regenerate the cordis catalog.
`agent/steering` and `agent/stream-chunk` are explicitly out of scope (not
durable-boundary mirrors). ACP is unaffected — it already settles from the log's
`turn/end` + `agent/status`; snapshot goldens are byte-unchanged.
Reshape the agent's interception surface so every seam returns a small, typed
Decision union, and the set covers the hook points a CC/Codex bridge (and a
native plugin) needs. "Native hooks" are not a package — a native hook is just a
cordis plugin on these canonical events; the bridges (a later PR) only translate
an external protocol onto the same surface.
dsh-agent:
- NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource
startup|resume|clear|compact) — a pure notification, seeds context via inject().
- NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the
prompt or attaching additionalContext, or block).
- RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} |
{action:'continue', reason?}; a continue reason is recorded as next-step steering).
- New HookContext envelope (required source — inject() would mislabel a missing one).
dsh-tools: split the single tools/execute waterfall into tools/pre-execute
(PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision
accept/block, optionally replacing content or attaching additionalContext). Core
dispatch sits between as plain code; the tool body keeps its inner try/catch so a
thrown tool still reaches post-execute as an isError. ToolExecutionResult gains
additionalContext (ferried to the loop's per-step buffer). Input rewrite is
deliberately NOT offered (a proposed RFC designs it consistently).
dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was
blocked by prompt-submit.
agent-loop firing points: session-start emitted at create (source threaded —
startup for create/fork, resume for resume()); prompt-submit per drained message
with the always-open-turn rule (a fully-blocked batch is a zero-step rejected
turn); the continuation reshape; post-tool additionalContext buffered and appended
after all tool/results (adjacency). ACP codec maps rejected→cancelled.
A worked native-plugin example (interception.spec.ts) proves all four seams compose
end-to-end through the real loop with NO hook/* events (those belong to the bridge
lib). All existing tools/execute + turn-continuation tests migrated. The
tool-subagent abort test now aborts after a microtask so it still exercises the
live onAbort bridge (execute() awaits pre-execute before the body runs).
RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) +
proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
Reform the compaction blueprint so a runaway turn survives and the design
stops drifting across review rounds:
- Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head
whole-unit walk; the only structural guard is step-alignment. A single turn
that alone exceeds the window now compacts its own early closed steps instead
of being retained verbatim (the failure mode that motivated this).
- Move auto-compaction off the agent/request waterfall onto a new awaited
agent/pre-request loop seam, fired before history derivation. Compaction
mutates the surface; the loop derives once from the result — no double-derive,
and a listener structurally cannot act on not-yet-derived messages.
- Tighten compactIfNeeded to required (session, system, model, signal).
- Enforce a single-pass convergence invariant in resolveConfig: reject configs
where summarizationMaxTokens + retainTokens exceeds the threshold, so a
compaction can never immediately re-trigger.
- Document the crash vs recoverable failure taxonomy; core session repair stays
compaction-agnostic (a log-only orphaned compact/start is inert).
- Wire dsh-compact-basic into examples/coding-agent and add a with-key
compaction e2e (compaction's first real-world exercise + runaway net).
- Rewrite the RFC to encode the blueprint and move it to implemented/.
The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot
yet serve the interleaved summarization model call.
Adds the @deepseek-ai/dsh-compact interface package: the abstract
CompactService (ctx.compact) with compactIfNeeded / compactRegion, the
compact/* session-event types via SessionEventMap declaration merging, and the
capability-seam RFC. Wires the package into the three root tsconfigs and the
cordis catalog. A backend implementation lands separately.
The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the
in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a
spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the
direction-inverted twin of the dsh-acp server bridge. Point the configured
command at the acp-agent example and the harness talks to its own process.
- Fresh process per run: start spawns, runs one ACP session (initialize →
newSession → prompt), dispose kills the subprocess and awaits its exit.
- Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk
text as the result output; auto-answers session/request_permission by a
configured policy (reject default / allow). No start-time capabilities (an
out-of-process child can't enforce the parent's depth/tool-filter); ignores
request.parent; injects only `subagents`.
- StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves
error/aborted on a child failure, never rejects (seam contract).
- Security: credential-shaped ambient env vars are scrubbed; the child's own key
is forwarded only via explicit config.env. A spawn-level error (ENOENT) is
captured and raced against the ACP drive so a bad command settles error rather
than crashing the parent.
Testing designed at every tier: keyless integration drives a scripted mock ACP
server subprocess (cancellation incl. the pre-newSession race and a
torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn
failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL
acp-agent example process (PONG + real file write, verified on disk) — the
harness driving itself. Snapshot coverage of an ACP child is deferred as
TODO(acp-subagent-replay) (each child is its own process with its own replay).
Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only
deprecates the stable ClientSideConnection/AgentSideConnection API this layer
uses (33 sites incl. the server bridge), turning no-deprecated red across code
this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The
backend needs nothing 0.28.x adds.
This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5
snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended.
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.
A request signal aborted BEFORE the run starts never fires an `abort` event
(`addEventListener` only fires on the transition), so the backend-level bridge
missed it and ran the child to `completed`. The driver now checks
`request.signal?.aborted` at the top of the result path and settles `aborted`
without running the child. Regression test proven red on the pre-fix code.
Also refresh two stale RFC prose blocks the round-1 fix left behind: the
subagent RFC's Problem statement (cited the removed `TODO(sub-agents)` markers
and claimed nothing existed yet) and the unify-id RFC's fork/spawn risk bullet
(described the seam as "explicitly deferred" via `AgentLoop.create`'s old TODO),
now pointing at the realized seam.
The second PR of the subagent seam: the two in-process backends that run a
child agent on the same cordis context, reusing the agent factory's quiescent
AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider
registry) and share one run driver.
- dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the
parent's model by default (overridable), zero inherited conversation. Also
exports the shared in-process run driver (startInProcessRun): mint ids, stamp
cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read
the last assistant/message + turn/end reason, dispose to quiescence.
- dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn
prefix (the log up to and including its last turn/end), so the child inherits
context. The in-flight unbalanced turn is excluded — a raw seed would fail the
invariants replay. Proven: a regression test goes red if the boundary seeds
the open turn.
- Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent
→ ctx.sessions.prepare({ seed }) (the primitive resume already used). This is
the fork-lineage path the TODO(sub-agents) markers anticipated.
- Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for
a child); the depthLimit capability refuses a spawn past request.maxDepth.
Tests: real-loop unit tests for both backends (mock MODEL only, real loop +
invariants), a multi-subagent test (one parent drives a fork AND a spawn child
then keeps working), and a with-key e2e (a real parent delegates via the
`subagent` tool to a real child that writes a file on disk — world-verified).
100% per-file coverage. The coding-agent demo wires the spawn backend + tool.
Snapshot coverage of nested agents is deferred to a stacked follow-up
(TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor
that cannot route calls to a parent vs. a child on one context. Recorded in the
RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its
test infrastructure END TO END up front, verifying the snapshot/e2e harness can
express the new shape — a gap this plan hit.
Address four findings from the first Codex review round:
- Contain subagent/start|end listener throws (emitContainedStart/End): a
thrown lifecycle listener could escape SubagentService.start() before the
caller received the live run to dispose it (a leaked child), and a thrown
subagent/end listener could surface as an unhandled rejection on the detached
result-settle hook. Both emits now log-and-contain, mirroring the agent
registry's agent/created|disposed containment.
- Make the model-facing tool name configurable (Config.toolName, default
subagent). The docs say to load dsh-tool-subagent once per provider to expose
multiple transports, but the hardcoded name made the second load throw a
duplicate-tool-name error; a distinct toolName per load is now required and
documented.
- Reach the per-file 100% coverage gate: tests for the subagent/end error
branch, lifecycle-listener containment, every stopReasonError arm + the
merge-extensible default, the multi-provider toolName path, agentOptions
forwarding, and the direct-apply schema-bypass fallbacks.
- Document the seam vocabulary in docs/core-data-structures/subagent.md with
verbatim type-equiv blocks + manifest entries, and link it from core.md (a
brand-new core/seam type the doc-sync gate cannot detect on its own).
Introduce the `packages/subagent/` group and the abstract subagent seam — an
agent delegating to a child agent — as a named-provider registry (`ctx.subagents`),
unlike the single-implementation bash seam, so multiple transports (in-process,
ACP, future A2A) coexist. This first PR lands the interface, a scripted test
backend, and the model-facing tool, validated through the real cordis load path.
- dsh-subagent: SubagentService registry + SubagentProvider/SubagentRun
vocabulary + subagent/start|end events. Start-time capabilities (outputSchema,
depthLimit, toolFilter) are checked pre-start and rejected loud; runtime
capabilities (sendMessage, resume) are optional methods on SubagentRun.
- dsh-subagent-mock (support): scripted provider for keyless, deterministic
tests through the real Loader/export path.
- dsh-tool-subagent: the model-facing `subagent` tool, config-bound to one
provider; synchronous collect with try/finally dispose, signal->cancel
bridging, and non-completed-stop-reason -> isError mapping.
- Proposed RFC documenting the seam, the fork-vs-spawn-as-separate-backends
decision, own-session isolation, synchronous-collect scope, and the deferral
of background/poll/spill to a future unification with bash.
- Wire the new group into tsconfigs, build refs, package hierarchy docs, the
module graph, and the cordis catalog.
RFC: docs/rfc/proposed/feature/2026-06-21-subagent-capability-seam.md
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.
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.
Codex's confirmation pass found the teardown-framing error went deeper than the
three prose spots already fixed: the whenIdle() JSDoc itself (and its mirrors)
claimed "the quiescence signal a teardown awaits ... a lifecycle owner disposes
the agent through its AgentHandle which ... awaits THIS". The disposer does not
call whenIdle() — it does `stop(); await agent.done` directly
(packages/core/agent-loop/src/index.ts:271). whenIdle() is the NON-OWNER
observation hook; owner teardown awaits the loop-exit promise (done) through
AgentHandle.dispose(). Reframe every copy accordingly:
- packages/core/agent/src/types.ts: the Agent.whenIdle() contract JSDoc.
- packages/core/agent-loop/src/agent.ts: the impl JSDoc.
- packages/core/agent/README.md and docs/core-data-structures/core.md (the
type-equiv mirror of the types.ts JSDoc — re-copied verbatim).
- docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md:40 and :70:
owner teardown via AgentHandle.dispose(); a non-owner observing quiescence
uses the interface-level agent.whenIdle(), not hand-rolled agent/status.
- Regenerate the cordis catalog (whenIdle source line moved).
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.
Codex's second pass caught that the prior doc fix swapped one wrong primitive
for another: framing teardown as cancel()+whenIdle() (or awaiting
agent.whenIdle() on disposal) is still wrong. whenIdle() only OBSERVES
quiescence; cancel() only stops queued/in-flight work. Neither unregisters the
agent or detaches the session. Real teardown is AgentHandle.dispose(), whose
disposer does `stop(); await agent.done` — stop the loop, await its exit, and
unregister (packages/core/agent-loop/src/index.ts:271). Copying the old framing
would reintroduce the orphaned-agent/session leak the AgentHandle seam exists
to prevent.
- docs/architecture.md: whenIdle() is a non-owner quiescence-observation hook,
explicitly NOT teardown; teardown is `await AgentHandle.dispose()`.
- docs/cookbook/extension-cookbook.md (prose + the ts comment): tear agents
down via AgentHandle.dispose(), not agent.whenIdle().
- docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md: the
lifecycle/disposal paragraph routes teardown through the handle's dispose().
Codex's no-ship was a completeness/docs-sync gap, not loop behavior:
- docs/architecture.md: drop the public abort() handle row; the teardown
signal is now cancel() then await whenIdle().
- cancel.spec.ts: the module doc and the turn-start comment contrasted
cancel() against a public abort() verb that no longer exists — reword to
name the loop's private step AbortController.
- packages/ui/acp/src/index.ts: the post-resume-leak comment cited abort();
cancel() is the surviving stop verb that likewise does not unregister.
- Move the RFC proposed -> implemented/simplification with amended text:
Status flips, the both-removal proposal is narrowed to abort-only, and an
implementation note records why whenIdle() is retained (load-bearing
quiescence primitive with live ACP consumers). Update docs/rfc/README.md.
- AGENTS.md "RFCs are proposals, not golden truth": add the concrete
abort/whenIdle worked example now that the implemented RFC exists to link.
- Regenerate the cordis catalog (line-number drift from the rebase).
The public Agent handle exposed abort() (step-only) and cancel() (queue-aware).
No production caller used abort() — ACP maps session/cancel to cancel(), and
lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths
abort their per-step AbortController directly. So abort() is latent generality
that keeps a private loop mechanic public.
RFC-premise correction: the public-agent-stop-surface RFC proposed removing
whenIdle() too. Implementation found whenIdle() load-bearing — a real
quiescence primitive with a deliberate loop contract (settle-without-transition,
the replacement-turn race) and ACP test consumers; its proposed replacement
("observe the running->idle transition") is exactly the async-state race
AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC
is amended on the way to implemented/ to record the narrowed scope, and the new
AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its
worked example.
- Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg
'aborted' default goes with it (cancel() keeps its 'cancelled' default).
- Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes
tests whose subject is the in-flight step's AbortController drive that
controller directly via the private currentAbort field (cancel() would clear
the inbox and destroy the queued steering one of them proves survives a step
abort). The no-arg-default test is dropped (cancel()'s default is already
covered in cancel.spec.ts).
- Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop
READMEs, architecture.md, core.md type-equiv, the extension cookbook, the
lifecycle RFC (short note), and the proposed ACP RFC.
Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
The original prune removed BashExecutor.get()/.list() too, but each is a
one-line accessor over the executor's already-tracked tasks map, and removing
them forced dsh-tool-bash's tests onto a ~35-line onTaskDone completion-tracking
harness just to replace the one-line ctx.bash.get(id) lookup. Per the AGENTS.md
"RFCs are proposals, not golden truth" principle, that disproportionate
migration cost is evidence the methods earn their keep — a test harness IS a
consumer programming against the seam.
Restore get()/list() (seam + LocalBashExecutor impl + the bash tests that used
them, dropping the doneFor/trackCompletions scaffolding). The persistence
has()/delete()/deleteStored removal stands — it had only contract-test callers
and no test-ergonomics cost. The RFC is retitled persistence-only with an
implementation note recording the bash revert.
Second Codex pass caught two proposed RFCs that describe the bash seam as it
WAS (with get/list) and would read stale once this prune lands, plus a latent
test-helper edge:
- docs/rfc/proposed/architecture/2026-06-20-branded-ids.md and
2026-06-20-generic-long-running-tool-runtime.md: drop get/list from the
BashExecutor seam description (surviving: resolve/run/start/ownerOf/
readOutput/kill/onTaskDone). branded-ids will be further updated when it is
implemented; this keeps it accurate in the meantime.
- trackCompletions now records every completion to `done` unconditionally (and
also wakes a parked waiter), so a second doneFor(id) after completion resolves
instead of hanging.
Two capability seams carried abstract methods no production consumer calls.
A method no consumer programs against is not a seam — it is speculative
surface every implementation must still provide and test.
- SessionPersistence: remove has() and delete(), the coordinator's
has/delete/deleteCore, and the PersistenceBackend.deleteStored hook (with its
jsonl + sqlite + in-spec memory-stub impls). Surviving service surface:
create/append/load/list. Production uses only load() (resume) and list()
(ACP session/list).
- BashExecutor: remove get(id) and list(), the abstract decls and the
LocalBashExecutor impls. The internal tasks map survives (it backs
ownerOf/readOutput/kill); get/list were pure public accessors over it with no
shipping caller and no bash_list tool.
- Migrate tests that reached through ctx.bash.get(id) to the public completion
seam: a doneFor(id) helper over onTaskDone awaits a task by id, and the
HMR-reload ownership test now proves task survival through A's own bash_output
([status: running]) plus ownerOf + B-rejection — a stronger through-the-tool
assertion than the removed lookup peek.
- Update seam READMEs (six -> four service methods, drop the deleteStored hook
and the get/list row) and the two implemented persistence RFCs in place.
Implements docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
The LLM service exposed three call surfaces (stream/streamBlocks/generate) but
the only production consumer — the agent loop — uses stream() exclusively,
feeding raw chunks through its own BlockAssembler for replay fidelity. Drop the
speculative convenience surfaces and the registry-change event that no listener
consumed, leaving stream() as the single model-call contract for both
production and tests.
- Remove LlmService.streamBlocks() and generate(), the llm/generate waterfall,
and GenerateResult.
- Remove the llm/adapter-change event (declaration + emits) and the
listener-throw rollback ordering that existed only to protect it; keep the
HMR rollback disposer.
- Remove BlockAssembler.flushReady()/flushRemaining()/result() and the flushed
cursor — the streaming-flush slice existed only for streamBlocks().
- Adapter tests drive a stream()+BlockAssembler helper (tests/assemble.ts)
instead of generate(), exercising the same path production uses.
- Land the AGENTS.md "RFCs are proposals, not golden truth" principle and move
both RFCs proposed -> implemented.
Implements:
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
The merge brought in two new proposed/architecture RFCs that reference
the old flat packages/<name> paths and the package-hierarchy RFC's old
proposed/ location. Rewrite their package paths to the grouped layout and
repoint the cross-link to implemented/architecture/ — the verify-package-paths
and verify-md-links gates caught both.
git mv it from proposed/ to rejected/architecture/ (preserving history)
and fold the supersede note into a one-line rejected Status. The
extract-example-app-packages RFC subsumes it: once the spine moves into
dsh-agent-core and the base*.yml files are deleted, there is no shared
base YAML left to rename. Move its README row to Rejected -> Architecture
and repoint the supersede cross-link to the new path.
Add a README to each group dir (core/llm/bash/session-persistence/ui/
support) stating its role and product-vs-support classification, and
rewrite packages/README.md around the hierarchy (group table, grouped
"what goes where", removed the package-hierarchy FIXME).
Move the package-hierarchy RFC to implemented/architecture/ and rewrite
it to describe what shipped (placement rationale, the paths-wildcard and
publint dedup, the two new guardrail gates). Fold the remaining
tsconfig.build.json references dedup into the discover-package-inventory
proposal and fix its cross-link.
Update AGENTS.md: regrouped repo-layout map, depth-2 globs, the new
verify-package-paths gate in the doc-sync listing, and a note that we
lean toward stricter lint in the agentic-coding era (machine-caught
errors and a consistent foundation outweigh the one-time cost).
Self-review against the actual source surfaced three imprecisions:
- agent-loop placement: the core bundle forwards agent-loop's `agents`
list as its own config (default []), matching AgentLoop.Config, rather
than hardcoding []. This is what lets a shared core coexist with stdio
pre-creating `main` and acp pre-creating none — and it directly rebuts
the reason base-core.yml gives today for keeping the loop out of core.
- timer is universal and stdout-safe, so it lives in the shared spine,
not the per-app front-door cluster (only logger + hmr are app-specific).
- model/systemPrompt land in different places per app (stdio onto the
pre-created agent, acp onto the bridge plugin), routed by the app
package's own Config — not a single uniform bundle entry.
Merge brought in the RFC-classification reorg and two new doc gates;
rewrite every drifted packages/<name> cross-link (Markdown link targets,
moved-README relative depths, and .ts comment paths) to the grouped paths.
Add two doc-sync/hygiene gates so the manual checks this restructure
needed become automated:
- verify-package-paths.ts: flags a packages/<path> reference (in Markdown
or a .ts comment/string) that does not resolve AND names a real package
in a segment — i.e. a stale path to a MOVED package. A path naming a
non-existent package (a forward-looking proposal) is left alone, so it
applies uniformly across proposed/implemented/rejected.
- check-workspace-constraints: assert the packages/<group>/<pkg> depth-2
shape (group dirs carry no package.json; no flat or over-nested
packages). Group names stay open; only the shape is fixed.
- Correct the SessionStore signature: `get(id: string)` is non-optional,
not `get(id?: string)` (only create/prepare take an optional id).
- Broaden the ACP brand-erosion description and the acceptance criterion
beyond `Map<string>`: the session-id surface also includes the
`bySession` WeakMap, the `loadingIds` Set, and the exported
`streamSessionEventUpdate(sessionId)` signature.
- Use bare inline code spans for code paths instead of markdown links,
matching the house style of the other architecture RFCs and removing the
link/bare-span inconsistency within this file (doc-to-doc cross-links
stay markdown links per docs/AGENTS.md).
Add a proposed architecture RFC to make the examples folder thin: each
example becomes mostly an invocation of an app package. A shared
dsh-agent-core bundle owns the providerless spine; dsh-stdio-agent and
dsh-acp-agent app packages bake in their coupled front-door cluster
(UI + logger/hmr policy + agent pre-creation), turning the ACP
stdout-purity footgun into a property of the artifact. Leaf cordis.yml
shrinks to backends + config; start.ts is dropped in favor of a package
bin.
Supersedes the providerless-example-base RFC (cross-linked) and indexes
the new RFC under Proposed -> Architecture.
Extend the existing Branded<B> machinery (CallId/SessionId/AgentId) to the
unbranded cross-boundary IDs that meet the brand.ts policy bar — chiefly the
model-facing bash task id (BashTask.id, the `bash-N` counter that shares
SessionId's `name-N` shape) and a distinct OwnerToken brand for the bash
owner token — and fix the brand erosion where existing brands decay back to
`string` at Map keys and method params.
Scoped focused per the "not every string needs a brand" policy: ModelId,
ToolName, numeric ordinals, and validated construction are listed as
deferred extensions, not in-scope work. Filed under proposed/architecture.
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.