Commit Graph
14 Commits
Author SHA1 Message Date
Tianyi Cui 09531a4e1b Merge remote-tracking branch 'origin/worktree-hooks-d-subagent' into worktree-hooks-e-protocol 2026-07-01 15:58:39 +08:00
Tianyi Cui 198ad2ef0f Merge remote-tracking branch 'origin/worktree-hooks-c-interception' into worktree-hooks-d-subagent 2026-07-01 15:52:58 +08:00
Tianyi Cui 3712f67bc6 fix(events): address review — core.md turn-only taxonomy, RFC mechanism names, post-execute content snapshot
- core-data-structures/core.md: the `agent/*` taxonomy said "turn/step
  boundaries", but the step-boundary mirror emits were dropped — `agent/*`
  mirrors only turn boundaries; step boundaries are durable `step/start`/
  `step/end` session events. Narrow the catalog so plugin authors aren't pointed
  at nonexistent `agent/*` step events.
- interception-seams RFC: replace stack-position phrasing ("a later stack PR",
  "the stack's first change", "the PR that makes...") with durable mechanism/RFC
  names (the hook bridge packages, the event-domain-semantics RFC).
- tools/post-execute snapshot: `dispatched.content` was the same array reference
  as `result.content`, so a listener's in-place `push`/`splice` leaked into the
  returned content while a reassignment was masked — the "protect from tampering"
  comment over-claimed. Copy content into a fresh array so the snapshot guards
  the array structure; comment now states it is not deep immutability. Regression
  extended to push a block in-place and assert it does not leak (proven red
  without the copy).
2026-07-01 15:39:08 +08:00
Tianyi Cui c658f4d155 fix(hooks): address Codex review — tighten codec to the reference schemas, preserve stdout
Codex's PR-E review found three protocol-fidelity blockers + two doc gaps, all
verified against ~/repos/refs:

- (A) Top-level `decision` accepted allow/deny/ask, but both reference schemas
  reserve those for hookSpecificOutput.permissionDecision — the legacy top-level
  decision is approve/block ONLY. Split topLevelDecisionOf (approve/block) from
  permissionDecisionOf (allow/deny/ask), so an out-of-band {"decision":"deny"} is
  now invalid and ignored instead of becoming a real blocking decision.
- (A) hookSpecificOutput was parsed without its hookEventName discriminator.
  HookOutput now surfaces hookEventName so a bridge can discard a block whose
  claimed event doesn't match the firing one (the schemas key the block by event).
- (A) runHook discarded raw stdout. HookOutput now carries `stdout` (trimmed,
  verbatim) so a bridge can reproduce CC's plain-stdout rendering / Codex's
  plain-stdout-as-additionalContext behavior.
- (B) hook/* SessionEventMap variants were only named in prose; added a payload/role
  table to core-data-structures/session.md (a maintained catalog surface).
- (B) Removed PR-stack-position references (PR-F / "future bridge packages") from a
  test comment and the RFC, per the current-state-wording rule.

New codec tests: top-level allow/deny/ask invalid+ignored, hookEventName capture,
raw stdout preserved on plain + JSON + empty stdout. 51 tests, per-file 100%.
2026-07-01 01:12:04 +08:00
Tianyi Cui 65165b5d54 feat(hooks): dsh-hook-protocol — shared Claude Code / Codex hook wire-protocol core
The two hook bridges (dsh-hooks-claude, dsh-hooks-codex) would otherwise duplicate
the bulk of the protocol — Codex deliberately reimplements a SUBSET of the Claude
Code protocol (same hooks.json shape, exit-code/stdout contract, command-hook
model). This library holds the genuinely-identical primitives; each bridge owns
only what differs (per-event stdin payload, env/substitution, decision mapping).

New packages/hooks/ group; hook-protocol is a LIBRARY (no plugin, registers/injects
nothing):
- matcher: matchesMatcher(pattern, query, mode) — the one dialect axis collapsed to
  a mode param (claude = literal-or-regex with pipe alternation; codex = always
  unanchored regex). Match-all on absent/''/'*'; invalid regex matches nothing.
- codec: parseHookOutput(exit, stdout, stderr) → dialect-neutral HookOutput. Exit 0
  → lenient JSON; exit 2 → blocking error (stderr = reason, surfaced as
  decision:'block'); other → non-blocking. Parses the CC superset
  (continue/stopReason/decision/hookSpecificOutput.{permissionDecision,
  additionalContext,updatedInput}/systemMessage); permissionDecision overrides the
  legacy top-level decision.
- runner: runHook(bash, hook, opts, now) — runs a command hook via ctx.bash (stdin
  payload + trusted-plugin env), honors timeoutSec, never throws (executor reject →
  non-blocking-error HookOutput). Injected clock for testable durations.
- merge: mergeHookOutputs — most-restrictive fold (deny>ask>allow, sticky stop,
  block reasons joined, context/system-messages accumulated).
- hook/* session events (declaration-merged into SessionEventMap, log-only like
  compact/*) + appendHookInvoked/appendHookResult helpers.

updatedInput is parsed but NOT honored (deferred pre-tool-input-rewrite RFC); a
bridge logs+warns. 47 unit tests at per-file 100% (matcher per-mode, codec per
exit-code/field, runner plumbing w/ stub executor, merge precedence, hook/*
helpers). RFC: implemented/feature/2026-06-30-hook-protocol-lib.md.
2026-07-01 00:41:53 +08:00
Tianyi Cui 93106b87b4 fix(subagent): deep-clone lastAssistantMessage onto subagent/end (observe-only)
Codex review caught an observe-only violation: the subagent/end emit fires from a
detached `.then` registered BEFORE start() returns — so before the caller's own
`await run.result` continuation runs. Carrying `result.output` by reference let a
mutating subagent/end listener corrupt the SubagentResult.output the caller/tool
then consumes. structuredClone() makes the event a read-only snapshot. Added a
regression test that mutates the event's array and asserts the caller's result is
untouched; proven to fail red without the clone. Updated the RFC + READMEs to note
the clone is load-bearing for the observe-only guarantee.
2026-06-30 21:52:16 +08:00
Tianyi Cui 7cc7b9cf7f feat(subagent): enrich subagent/start + subagent/end lifecycle events (observe-only)
A hooks bridge translating SubagentStart/SubagentStop needs to know WHICH kind of
subagent ran and WHAT it produced — Claude Code's hooks carry subagent_type and the
child's final message. Enrich the existing lifecycle emits to match, observe-only:

- agentType: an optional caller-supplied subagent-kind label (CC's subagent_type),
  added to SubagentStartRequest and carried VERBATIM onto both subagent/start
  (SubagentRunInfo) and subagent/end (SubagentRunEndInfo). The seam never interprets
  it. dsh-tool-subagent threads it from a new optional Config.agentType, so a
  deployment exposing multiple subagent kinds (one tool load per kind) labels each.
- lastAssistantMessage: the child's final output (SubagentResult.output), added to
  SubagentRunEndInfo on the settle path so an observer sees what the subagent
  produced without holding the run. Absent on the reject path (no result produced).

Strictly observe-only: both events stay plain emits (subagent/end fires from a
detached .then and awaits no listener). A control-flow subagent/end (awaited
waterfall returning a decision) would need the emit→waterfall reshape, awaiting
listeners before settling, and a provider resume capability — deferred to the
background/steering redesign (FIXME(subagent-continuation) anchors it). RFC:
implemented/feature/2026-06-30-subagent-observe-enrich.md.
2026-06-30 21:29:08 +08:00
Tianyi Cui 483e0e5edf fix(events): address Codex review — protect post-execute result, purge stale tools/execute refs
Codex's PR-C review found two (A) blockers:

- tools/post-execute could corrupt the protected outcome. postExecute passed the
  mutable `result` to listeners and then read result.callId / spread result on the
  return paths, so a listener mutating the reference (flipping isError, rewriting
  callId, injecting an error) escaped the decision channel. Now the authoritative
  callId/isError/error are SNAPSHOT before the waterfall and the return value is
  rebuilt from the snapshot + the typed PostToolDecision — the decision is the only
  sanctioned way to change the outcome, and callId is always exec.callId. Added a
  regression test that mutates the result reference and asserts it has no effect;
  proven to fail red on the unfixed code.

- Public docs/JSDoc still advertised the removed `tools/execute` waterfall after the
  split. Swept every current-state reference to tools/pre-execute + tools/post-execute:
  the ToolRegistry class JSDoc (and the regenerated catalog), loop.ts's ASCII flow
  (also added the prompt-submit/session-start steps it was missing), the package-map
  READMEs (packages, core, agent-core), core-data-structures core.md/tools.md, the
  bash + acp + invariants src/READMEs (the deferred permission gate is the
  tools/pre-execute deny/ask seam now), the cookbook, and the implemented RFCs whose
  factual seam catalog drifted. codec.ts's totality prose now lists `rejected`.
  Proposed-RFC references are left as-is (frozen proposals, validated when built).
2026-06-30 20:25:30 +08:00
Tianyi Cui dc95a7881d feat(events): interception seams — the typed-Decision surface for hooks
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).
2026-06-30 17:11:18 +08:00
Tianyi Cui 9f1caf7c5b fix(todo): address todo_write review feedback 2026-06-29 19:44:38 +08:00
Tianyi Cui 46e31d8481 feat(tool-todo): add the model-facing todo_write tool
Add @deepseek-ai/dsh-tool-todo (a new packages/todo/ group): a model-facing
todo_write(todos: [{content, status}]) tool with whole-list-replace semantics.
Each call appends the full list as a todo/write event to the calling agent's
session log; the current list is the most recent such event (last-write-wins).
Single-owner — a non-agent caller is rejected. Beyond the schema's
type/required/enum checks, execute rejects empty/duplicate content and more than
one in_progress task, narrowing the loosely-typed args into a real TodoItem[].

Both UIs render off the existing session/event: the stdio UI prints a glyphed
checklist; the ACP bridge maps the list to a `plan` sessionUpdate (todosToPlan
synthesizes the priority ACP requires; status maps 1:1). Wired into the
coding-agent, acp-agent, and snapshot example configs with a system-prompt nudge.

Tests: unit (schema, validation, append/replace, no-agent rejection, presentCall,
HMR-safety, Loader export-shape guard), full-loop integration through the agent
loop, the ACP todosToPlan mapping + stream-update arm, the stdio render arm, and
a session/load replay that re-emits the plan. New-group TS wiring added to
tsconfig.base/json/build. RFC + a doc-inventory sweep (architecture, packages
README, AGENTS layout, cookbook group list, example READMEs) ship with it.

The todo-plan ACP snapshot scenario is recorded separately (needs an API key).
2026-06-29 10:30:52 +08:00
Tianyi Cui f393043b03 Add the ACP subagent backend: out-of-process delegation (PR3)
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.
2026-06-22 10:47:02 +08:00
Tianyi Cui ca2207e26c Fix doc cross-links for the hierarchy; add package-path + shape gates
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.
2026-06-20 23:12:14 +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