From 06ae5749e270358f86f4bff818d60f4ca976028c Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 15:02:41 -0400 Subject: [PATCH 1/9] docs(goal): add Claude control-plane research loop --- .../GOAL.md | 165 ++++++++++++++++++ .../PROMPT.md | 55 ++++++ .../REFS.md | 80 +++++++++ .../RETRO.md | 121 +++++++++++++ .../SPEC.md | 95 ++++++++++ 5 files changed, 516 insertions(+) create mode 100644 .agents/goals/2026-07-15-claude-control-plane-research/GOAL.md create mode 100644 .agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md create mode 100644 .agents/goals/2026-07-15-claude-control-plane-research/REFS.md create mode 100644 .agents/goals/2026-07-15-claude-control-plane-research/RETRO.md create mode 100644 .agents/goals/2026-07-15-claude-control-plane-research/SPEC.md diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md b/.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md new file mode 100644 index 0000000..3084ffb --- /dev/null +++ b/.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md @@ -0,0 +1,165 @@ +# Goal Execution Contract: Claude Control-Plane Research + +Date: 2026-07-15 +Status: Active +Spec: `.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md` +Prompt: `.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md` +Retro: `.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md` +Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` + +## Completion Horizon + +`ready-pr` + +Complete when: + +- One research/decision PR contains the verified capability matrix, reproducible probe evidence, implementation-ready Claude provider plan, justified ADR updates/additions, and current tracker decomposition. +- The matrix covers every operation and lifecycle question with `verified`, `unsupported`, `product-decision`, or `blocked`; each blocked row names the exact blocker and next experiment. +- The disposable walking skeleton and failure/recovery sequences are completed, or a hard provider/tool blocker is proven through at least three materially different approaches and converted into a concrete decision path. +- Transport/protocol and security/product local reviews are 5/5 with zero open P0/P1/P2 findings; `just check`, docs/fixture checks, hosted CI, and review threads are clean; the PR is non-draft. +- `RETRO.md` carries the source/version ledger, experiment ledger, cleanup audit, confidence/contradiction log, review reports, Linear state, and final recommended milestone sequence. + +Not complete when: + +- The work only summarizes documentation, proves one happy-path prompt, or reports zmx/CLI exit success without hook-confirmed acceptance. +- Any operation remains an unlabeled unknown, semantics are inferred from Codex, or Agent SDK behavior is substituted for direct CLI evidence. +- The plan says “provider adapter” without specifying interfaces, capability negotiation, process ownership, receipt correlation, storage changes, security boundaries, tests, and ordered milestones. +- Disposable processes/sessions remain orphaned, live configuration changed, evidence contains secrets/raw transcripts, or tracker/docs disagree. + +## Authority + +- May browse/search: yes, prioritize official Claude documentation and primary zmx sources; record retrieval date and installed versions. +- May run local probes: yes, only disposable Claude sessions in temporary repositories, using the cheapest suitable model, minimal turns, bounded budgets where supported, and isolated per-session settings. +- May inspect existing sessions: metadata-only and read-only; do not send, interrupt, attach interactively, rename, or mutate them. +- May write: goal packet, `docs/research/`, `docs/development/`, relevant ADRs, `spikes/claude/`, sanitized fixtures/tests for probes, and scoped docs/skill corrections discovered by the research. +- May commit/push/open PR: yes, continue `docs/claude-control-plane-research`; submit one draft PR and mark ready only after gates pass. +- May mutate Linear: yes, scoped to DIS-9, DIS-49, directly related Claude-control issues, and dependency links/comments supported by evidence. +- May merge/release/publish: no. +- Needs user approval for: global/project Claude settings changes, messaging existing sessions, installing/upgrading zmx or Claude, private endpoints, auth/keychain access, production implementation, or expanded remote/gateway scope. + +## Boundary + +- In scope: direct Claude CLI/Agent View/session behavior, zmx transport, hooks/events/receipts, operation semantics, process supervision, provider-neutral integration plan, security/privacy, fixtures, docs, ADRs, tracker decomposition. +- Out of scope: production adapter implementation, public CLI provider flags, database migrations, mesh/SSH implementation, Slack/Linear gateway implementation, Claude SDK as the chosen runtime, release/publish. +- Do not touch: existing session input/state, `~/.claude` or project settings, auth files, tokens, keychain/cookies, raw user transcripts, unrelated worktrees/branches/issues. + +## Topology + +Delegated research goal with one primary synthesizer. Bounded subagents may independently audit official surfaces, current Dispatch seams, zmx/process behavior, hooks/security, or review artifacts. The primary agent owns experiments, conclusions, edits, tracker mutations, source control, and contradictions. + +Callback: use the coordinator thread ID from `PROMPT.md` with `codex_app.send_message_to_thread`. Ping only for ready pickup, coordinator-needed, user-needed, blocked-with-evidence, surprising findings, or material scope/risk changes. Final callbacks must include the capability verdict, walking-skeleton result, artifacts, checks, open decisions, and next implementation slice. + +## Steps + +### 1. Baseline and question ledger + +- Record repo commit, Dispatch/Claude/zmx versions, current official docs, installed CLI help/schema, current DIS-9/DIS-48/DIS-49 state, and current Dispatch provider architecture. +- Create the capability/unknown ledger before experimenting. Every later observation must resolve or refine a named row. +- Gate: `RETRO.md` has a baseline and cleanup inventory; no live state mutation occurred. + +### 2. Supported surface verification + +- Verify official semantics and local behavior for Agent View/background agents, `--resume`, `--continue`, `--fork-session`, `--session-id`, names, worktrees, permission modes, stream-JSON input/output, hook-event streaming, remote control, settings precedence, and hooks. +- Record exact JSON/event schemas as sanitized fixtures where stable and useful. Separate documented guarantees from observed implementation details. +- Gate: source/version matrix reviewed for omissions and contradictions. + +### 3. Session lifecycle and identity experiments + +- In a temporary repository, create named disposable sessions through each plausible launch path. Determine when the session ID becomes known, how it maps to Agent View, and whether it survives detach, resume, process exit, and a new shell invocation. +- Test `--resume `, `--continue`, explicit `--session-id`, fork behavior, background agents, and any supported attach/control path. Do not touch pre-existing sessions. +- Gate: lifecycle state machine and identity mapping are evidence-backed; cleanup ledger is empty. + +### 4. Message transport experiments + +- Compare direct background/Agent View control, interactive PTY, stream-JSON, remote control, resume-based delivery, and zmx-backed interactive sessions. +- Prove cross-process input framing, acceptance, completion, repeated turns, backpressure, terminal behavior, and concurrent-writer handling. Use Dispatch message IDs and per-session hooks for correlation where supported. +- Gate: walking skeleton launches a disposable session, sends from another process, proves provider acceptance and completion, interrupts safely, resumes/attaches, and completes a second message, or records a hard supported-surface blocker. + +### 5. Hooks, receipts, and attention + +- Exercise supported lifecycle, prompt, tool, permission, notification, elicitation/user-input, stop, subagent, and session hooks with per-session settings. +- Establish event ordering and map events to `provider_events`, `message_receipts`, `lane_runtime_state`, queue-drain readiness, inbox/attention, and completion. +- Prove hooks compose with existing settings without global mutation and define failure behavior, timeouts, output limits, and spoof-resistant correlation. +- Gate: sequence diagrams and sanitized fixtures support the receipt/attention contract; scrollback is diagnostic only. + +### 6. Operation capability matrix + +- Resolve `new`, `attach`, `send`, `steer`, `queue`, `interject`, `context`, `stop`, `tail`, `watch`, `rename`, `archive`, `restore`, `goal`, permissions, structured output, and rich input. +- For each, name the Claude primitive, Dispatch composition, preconditions, receipt, failure modes, recovery, confidence, and whether the operation is unsupported or needs a product decision. +- Gate: two reviewers agree there are no false semantic equivalences or naked unknowns. + +### 7. Failure, restart, and security matrix + +- Test process death, zmx/transport loss, duplicate/retried messages, simultaneous sends, interrupt races, permission waits, user-input waits, stale identity, malformed hooks, hook timeout/failure, and Dispatch daemon restart. +- Threat-model PTY/control-byte injection, shell quoting, environment inheritance, settings precedence, sender spoofing, raw logs/transcripts, and cross-session routing. +- Gate: each failure has deterministic ownership, visible state, retry/idempotency rule, and cleanup/recovery path. + +### 8. Implementation plan and tracker decomposition + +- Produce `docs/development/claude-provider-plan.md` with provider interfaces, capability negotiation, session supervisor, transport choice, hooks/settings strategy, event/receipt storage, selector/routing changes, config/presets, CLI/MCP projection, migration needs, fixtures, rollout, and ordered milestones. +- Produce/update research evidence and ADRs. Reconcile DIS-9 and DIS-49, then create focused implementation issues with dependencies and acceptance criteria. The first slice must be the smallest end-to-end walking skeleton, not an abstraction-only PR. +- Gate: a fresh engineer can implement the sequence without reconstructing research or guessing semantics. + +### 9. Review and ready-PR closure + +- Run transport/protocol and security/product local-review loops. Fix P0-P2 and reasonable P3 findings, rerun affected experiments/checks, then run a full-stack research review. +- Run `just check`, packet doctor, prompt checker, docs/fixture validation, hosted CI, and thread reconciliation. Mark ready only when all evidence and tracker state agree. + +## Reviews + +- Run independent transport/protocol and security/product review loops after each material milestone. +- Store structured review reports under `tmp/reviews/`; fix all P0-P2 and reasonable P3 findings before advancing. +- Finish with a full-stack research review at 5/5 and zero open P0-P2 findings. + +## Evidence Contract + +Required durable artifacts: + +- `docs/research/claude-control-plane-verification.md`: source/version ledger, capability matrix, experiment methods/results, contradictions, confidence, sanitized schemas, and unsupported findings. +- `docs/development/claude-provider-plan.md`: implementation architecture, operation mapping, milestones, risks, migration/testing/rollout, and explicit non-goals. +- `spikes/claude/README.md` plus the smallest reproducible scripts/fixtures needed to rerun claims; no raw transcripts, credentials, giant logs, or machine-specific paths. +- Relevant ADR additions/updates when a transport, lifecycle, receipt, or provider-boundary decision is justified. +- `RETRO.md`: chronological experiments, commands, costs where available, cleanup, review reports, tracker/PR state, final proof, and unresolved product decisions. +- Linear: evidence comments on DIS-9/DIS-49 and focused implementation issues with dependencies. + +## Verification + +- `/Users/mg/.agents/skills/goal-loop/scripts/check-goal-prompt --no-placeholders PROMPT.md` +- `/Users/mg/.agents/skills/goal-loop/scripts/goal-loop-doctor ` +- `just check` +- Reproducible probe commands documented and rerun from a clean temporary directory. +- Hash or metadata checks prove live `~/.claude` settings were unchanged without printing their contents. +- Before/after process, Agent View, zmx, temp-directory, and session cleanup inventories show no disposable leftovers. +- Official-source links include retrieval date; local claims include tool version and command. +- Local review JSON reports under `tmp/reviews/transport/`, `tmp/reviews/security-product/`, and `tmp/reviews/full-stack/`. +- PR CI/review threads are green and resolved before ready. + +## Next Move + +- When docs and runtime disagree, reproduce on the installed version, search current official documentation/release notes, record both, and lower confidence rather than choosing silently. +- When one transport fails, test a materially different supported path before calling the capability blocked. +- When an operation has no safe Claude equivalent, mark it unsupported or product-decision and continue the rest of the matrix. +- When a probe exposes sensitive data, stop that probe, remove the artifact, record the privacy failure without the value, and redesign the fixture. +- After three materially different failed approaches to a required walking-skeleton step, document the blocker and implementation consequence, then continue independent research. + +## Stop Rules + +Stop only when: + +- Continuing requires credentials/auth-file access, private endpoints, global/project settings mutation, messaging existing sessions, or installing/upgrading tools without approval. +- Required Claude functionality cannot be exercised because authentication/service access is unavailable after bounded retries and no isolated alternative exists. +- A security/privacy incident cannot be contained locally. +- The same hard blocker survives three materially different supported approaches and prevents the implementation plan from being honest; callback with exact evidence and a recommended decision. + +Do not stop merely because one operation is unsupported, a model call fails, documentation is incomplete, or an experiment needs redesign. + +## Waiting State + +No routine external waiting is expected. For transient model/service limits, wait once with a bounded retry and then switch to documentation, fixture, code-seam, or non-model experiments. Do not burn tokens polling. CI/review waits should be checked only on meaningful state changes. + +## Persistence And Resume + +- Update `RETRO.md` after every milestone, surprising finding, failed approach, tracker mutation, and review round. +- Keep raw or machine-specific experiment output under gitignored `tmp/`; promote only sanitized evidence. +- Resume by reading `GOAL.md`, the latest `RETRO.md` entry, the capability matrix, current git/PR state, and DIS-9/DIS-49 comments. +- Continue until the completion horizon or a stop rule is satisfied; a context boundary is not a reason to stop. diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md b/.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md new file mode 100644 index 0000000..f5b5421 --- /dev/null +++ b/.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md @@ -0,0 +1,55 @@ +/goal Verify Claude session control and deliver an implementation-ready Dispatch provider plan in a clean ready research PR, without production implementation. + +## Objective + +Turn every Claude control unknown into evidence, unsupported/product-decision, or a proven blocker. Leave ordered issues. + +## Authority + +Use $delegate-init. +Coordinator: [@Dispatch](codex://threads/019e8a09-5021-7b63-9d95-402b7c7d345e) +You: @Dispatch:ClaudeControlResearch +Title: `→ @Dispatch:ClaudeControlResearch Verify Claude Session Control` +Context: `Verify Claude Session Control` +Tracker: DIS-9, DIS-49, and directly supported implementation issues. +Continue `docs/claude-control-plane-research`; commit scoped work, open one draft PR, and mark ready after all gates. Do not merge, release, or publish. + +## Boundary + +Work in `/Users/mg/Developer/outfitter/dispatch`. Read `AGENTS.md` and the entire goal packet; `GOAL.md` is authoritative. Research official Claude CLI, Agent View, hooks, settings, streaming, remote control, zmx, and Dispatch seams. Use disposable temp-repo sessions, cheap models, minimal prompts, and per-session settings. Existing sessions are read-only metadata. + +## Sequence + +Ledger first; then surfaces, identity/lifecycle, transports, hooks/receipts, operations, failures/security, architecture, and tracker. Prove a persistent disposable-session skeleton: durable identity, cross-process send, hook-confirmed acceptance, completion, interrupt, resume/attach, and second message. Test transport loss, duplicate/concurrent sends, attention, hook failure, and cleanup. + +## Loop + +Per milestone: sources/code -> isolated experiments -> sanitized evidence -> matrix/RETRO -> transport and security/product reviews -> fix P0-P2 and reasonable P3 -> rerun. Finish with full-stack review, packet checks, `just check`, CI, resolved threads, tracker reconciliation, and 5/5 with zero P0-P2. + +## Hard Rules + +Never read auth/raw user transcripts; change global/project settings; message existing sessions; use private endpoints; or install/upgrade tools. Exit status and scrollback are not delivery proof. Do not infer Claude semantics from Codex or substitute Agent SDK evidence. + +## Stop Rules + +After three distinct supported approaches fail, record the blocker and continue independent work. Stop only for required approval/access, uncontainable security/privacy risk, or a blocker preventing an honest plan. + +## Definition Of Done + +Resolve every capability row in `RETRO.md`. Each needs its primitive/composition, preconditions, acceptance/completion receipts, failure/recovery, evidence/version/confidence, and one status: verified, unsupported, product-decision, or blocked. No naked unknowns or forced Codex parity. + +## Evidence Contract + +Deliver the research, provider plan, minimal `spikes/claude/`, ADR changes, current RETRO, and Linear issues required by `GOAL.md`. Settle provider boundaries, capabilities, supervision/transport, hooks/settings, receipts/storage, queue/attention/restart, security, CLI/MCP/config, migrations, fixtures, rollout, and the first walking-skeleton PR. + +## Next Move + +When docs and runtime differ, preserve both and lower confidence. Try distinct supported transports before blocking. Mark absent safe equivalents unsupported or product-decision and continue. Keep only sanitized reproducible evidence. + +## Not Done + +Do not implement the adapter, provider flags, migrations, mesh/gateway, or release. A summary, one happy path, unconfirmed zmx input, open P0-P2, draft PR, or orphaned state is not completion. + +## Persistence + +Update RETRO after every milestone, failed approach, surprise, tracker mutation, and review. Resume from packet, RETRO, matrix, git/PR, and tracker. Callback via the coordinator ID using `codex_app.send_message_to_thread`; do not discover your ID. Ping only for pickup, required input, evidenced blocker, surprise, or material risk. Final ping: verdict, skeleton, artifacts, checks, decisions, issues/PR, cleanup, and first slice. diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/REFS.md b/.agents/goals/2026-07-15-claude-control-plane-research/REFS.md new file mode 100644 index 0000000..abcf02a --- /dev/null +++ b/.agents/goals/2026-07-15-claude-control-plane-research/REFS.md @@ -0,0 +1,80 @@ +# References: Claude Control-Plane Research + +## Repository + +- `AGENTS.md` - repository workflow, no-drift contracts, source-control, tests, and safety rules. +- `docs/development/design.md` - approved daemon, lane, contract, and surface architecture. +- `docs/adrs/0002-single-daemon-over-one-app-server.md` - current Codex-only daemon ownership. +- `docs/adrs/0006-handler-context-and-di.md` - injected client/handler boundary. +- `docs/adrs/0007-normalized-internal-lane-events.md` - normalized lane event contract. +- `docs/adrs/0013-dispatch-mesh-is-daemon-federation.md` - future multi-machine constraints. +- `docs/adrs/0023-provider-event-log-and-history-index.md` - Claude hooks, receipts, provider events, and zmx as an adapter detail. +- `src/outfitter/dispatch/contracts/context.py` - current Codex client protocol. +- `src/outfitter/dispatch/core/handlers.py` - current `new`, `send`, steer, context, interject, queue, stop, and lifecycle behavior. +- `src/outfitter/dispatch/core/server_requests.py` - Codex attention/request behavior to compare rather than assume. +- `src/outfitter/dispatch/core/queue.py`, `subscriptions.py`, and `reactor.py` - provider-neutral queue and event consumers. +- `src/outfitter/dispatch/registry/models.py` and `registry/store.py` - lane/provider/event/receipt/runtime persistence seams. +- `spikes/README.md` - probe conventions. + +## Linear + +- [DIS-9](https://linear.app/outfitter/issue/DIS-9/map-claude-hook-events-onto-provider_events-after-codex-substrate) - existing Claude hook/event mapping issue and prior zmx findings. +- [DIS-48](https://linear.app/outfitter/issue/DIS-48/make-claude-usage-capture-installable-transparent-and-reversible) - Claude usage capture lifecycle; adjacent but not messaging scope. +- [DIS-49](https://linear.app/outfitter/issue/DIS-49/add-claude-and-codex-provider-selection-shorthands) - canonical execution-provider field and CLI shorthands. + +## Official Claude Sources + +- [Agent View](https://code.claude.com/docs/en/agent-view) - background agents, discovery, and control surface. +- [Hooks reference](https://code.claude.com/docs/en/hooks) - lifecycle, tool, prompt, permission, notification, stop, and session events. +- [CLI reference](https://code.claude.com/docs/en/cli-reference) - launch, resume, streaming, settings, permissions, remote control, and output/input formats. +- [Settings](https://code.claude.com/docs/en/settings) - settings sources, precedence, and hook configuration. +- [Headless mode](https://code.claude.com/docs/en/headless) - print/stream-JSON behavior; verify the current canonical URL if redirected. +- Claude Code release notes and local `claude --help`/subcommand help - reconcile docs against installed behavior. + +Record retrieval date, URL, relevant version, and whether each statement is documented, observed, inferred, or contradicted. + +## Local Baseline + +- `claude --version` -> `2.1.210 (Claude Code)` at packet preparation. +- `claude --help` advertises `--bg`, `agents`, `--resume`, `--continue`, `--fork-session`, `--session-id`, `--name`, `--input-format stream-json`, `--output-format stream-json`, `--include-hook-events`, `--replay-user-messages`, `--remote-control`, permission modes, per-session `--settings`, and worktrees. +- `claude agents --help` advertises JSON discovery plus model, effort, permission, settings, MCP, plugin, and cwd controls for dispatched sessions. +- `zmx version` -> `0.6.0`; `zmx help` documents persistent sessions, raw fire-and-forget PTY input, history, tail, wait, detach, and kill. +- `dispatch --version` -> `0.10.0`; current control operations remain Codex App Server-backed. + +## Required Baseline Commands + +```bash +git status --short --branch +git rev-parse HEAD +dispatch --version +dispatch doctor --json +claude --version +claude --help +claude agents --help +claude agents --json +zmx version +zmx list --short +zmx help +``` + +Do not paste sensitive or unbounded command output into tracked artifacts. Sanitize fixtures mechanically and record hashes/shapes where full content is unnecessary. + +## Required Research Artifacts + +- `docs/research/claude-control-plane-verification.md` +- `docs/development/claude-provider-plan.md` +- `spikes/claude/README.md` and minimal reproducible probes/fixtures +- Relevant ADR additions/updates +- `.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md` +- Local-review reports under the packet's gitignored `tmp/reviews/` + +## Verification Commands + +```bash +/Users/mg/.agents/skills/goal-loop/scripts/check-goal-prompt --no-placeholders \ + .agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md +/Users/mg/.agents/skills/goal-loop/scripts/goal-loop-doctor \ + .agents/goals/2026-07-15-claude-control-plane-research +just check +git diff --check +``` diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md new file mode 100644 index 0000000..ca53ce1 --- /dev/null +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -0,0 +1,121 @@ +# Execution Retro: Claude Control-Plane Research + +Date started: 2026-07-15 +Date finalized: pending +Status: Prepared for delegated execution +Spec: `.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md` +Goal: `.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md` +Prompt: `.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md` +Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` + +## Summary + +- Objective: verify Claude session-control semantics and produce an implementation-ready Dispatch provider plan. +- Completion horizon: `ready-pr`. +- Authority: isolated low-cost research, scoped docs/spikes/ADRs/tracker, commit/push/draft/ready PR; no production implementation, merge, release, publish, live settings mutation, private endpoint, credential access, or existing-session messaging. +- Current state: packet prepared on `docs/claude-control-plane-research`; execution not started. +- Known baseline: Dispatch 0.10.0 observes Claude but controls only Codex; Claude Code 2.1.210 exposes several candidate direct surfaces; zmx 0.6.0 raw send is fire-and-forget. + +## Readiness + +- Prompt checked: passed at 3,994/4,000 characters with no unresolved placeholders. +- Goal/prompt alignment: passed preparation review; authority, boundary, sequence, reviews, evidence, stop rules, and completion horizon agree. +- Review blockers: none known before execution. +- Verification blockers: none known; live Claude model access must be confirmed by the delegate. +- Tracker blockers: none; DIS-9 and DIS-49 exist, while implementation issue decomposition is an execution deliverable. +- Authority blockers: global settings changes, existing-session mutation, tool installation/upgrades, private endpoints, production implementation, merge, release, and publish require separate approval. +- Next action: run packet checks, commit preparation, then delegate the raw `PROMPT.md` body as the first message to the research agent. + +## Preparation Log + +```text +2026-07-15 - Packet preparation +- Verified: clean synchronized main at Dispatch 0.10.0; no open PRs. +- Verified: all public new/send/steer/context/interject paths still terminate in the Codex App Server client protocol. +- Verified: Claude Code 2.1.210 advertises background agents, Agent View JSON discovery, durable/resumable session selectors, realtime stream JSON, hook events, remote control, named sessions, settings, permission modes, and worktrees. +- Verified: zmx 0.6.0 supplies persistent PTY sessions and raw input but explicitly provides no send completion marker or exit status. +- Tracker: DIS-9 is the existing hooks/receipts research issue; DIS-48 is usage capture only; DIS-49 specifies execution-provider shorthands but excludes the Claude transport. +- Decision: use a research/decision goal with a ready-PR horizon and require a hook-confirmed cross-process walking skeleton before implementation planning is accepted. +- Validation: `check-goal-prompt --no-placeholders` and `goal-loop-doctor` passed; `git diff --check` passed. +- Mutation audit: created only this goal packet branch and packet files; no Claude session/config/tracker/remote mutation. +``` + +## Goal Amendments + +| Time | Change | Reason | Approved By | +| --- | --- | --- | --- | +| 2026-07-15 | Initial research-to-decision contract | User requested exhaustive research before Claude implementation planning | Matt | + +## Execution Log + +Execution has not started. Append chronological milestone, experiment, cleanup, tracker, and source-control evidence here. + +## Experiment Ledger + +| ID | Question | Method | Version/source | Result | Confidence | Artifact | Cleanup | +| --- | --- | --- | --- | --- | --- | --- | --- | +| prep-1 | Does Dispatch currently control Claude sessions? | Trace public handlers and client protocol | Dispatch 0.10.0 | No; control path is Codex-only | high | SPEC/REFS | n/a | +| prep-2 | Is zmx send a delivery receipt? | Inspect installed primary help | zmx 0.6.0 | No; raw input is fire-and-forget | high | REFS | n/a | + +## Capability Matrix + +The delegate must move every row to `verified`, `unsupported`, `product-decision`, or `blocked` and add evidence, confidence, failure/recovery, and implementation consequence. + +| Operation/capability | Status | Claude primitive/composition | Acceptance/completion evidence | Confidence | Next experiment | +| --- | --- | --- | --- | --- | --- | +| durable identity | pending | | | | | +| new | pending | | | | | +| attach/resume | pending | | | | | +| send | pending | | | | | +| steer during active turn | pending | | | | | +| durable queue/readiness | pending | | | | | +| interject | pending | | | | | +| context injection | pending | | | | | +| stop/interrupt | pending | | | | | +| tail/history | pending | | | | | +| watch/events | pending | | | | | +| rename | pending | | | | | +| archive/restore | pending | | | | | +| goal loop | pending | | | | | +| permissions/approval | pending | | | | | +| user input/elicitation | pending | | | | | +| structured output | pending | | | | | +| rich input/files/images | pending | | | | | +| process restart/recovery | pending | | | | | +| duplicate/concurrent send | pending | | | | | +| remote/mesh compatibility | pending | | | | | + +## Review Log + +| Round | Scope | Report | Score | State | Open P0-P2 | Notes | +| --- | --- | --- | --- | --- | --- | --- | + +## Verification Log + +| Check | Scope | Result | Notes | +| --- | --- | --- | --- | + +## Prompt / Goal Alignment + +- Preparation review passed on 2026-07-15. +- `PROMPT.md` delegates the same authority, boundaries, evidence contract, review gates, stop rules, and ready-PR horizon defined by `GOAL.md`. + +## Tracker / PR Log + +| Item | State | Notes | +| --- | --- | --- | +| DIS-9 | Todo | Existing Claude hooks/provider-events research issue | +| DIS-48 | Todo | Adjacent usage-capture lifecycle; not messaging transport | +| DIS-49 | Todo | Provider selector and CLI shorthands; transport out of scope | + +## Final State + +- Completion proof: pending execution. +- Walking-skeleton proof: pending. +- Capability verdict: pending. +- Implementation plan: pending. +- Review summary: pending. +- Verification summary: pending. +- Cleanup audit: pending. +- Remaining product decisions/blockers: pending. +- Recommended first implementation slice: pending. diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md b/.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md new file mode 100644 index 0000000..f0eedc2 --- /dev/null +++ b/.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md @@ -0,0 +1,95 @@ +# Goal Spec: Claude Control-Plane Research + +Date: 2026-07-15 +Status: Active + +## Objective + +Turn Dispatch's Claude session-control unknowns into verified capabilities, explicit unsupported semantics, or bounded product decisions, then produce an implementation-ready plan for launching, attaching, messaging, interrupting, observing, and recovering Claude sessions through Dispatch. + +## Context + +Dispatch 0.10.0 can observe Claude account, runtime, and statusline capacity, but every lane-control operation still targets the Codex App Server. DIS-9 captures earlier hook and zmx findings, DIS-48 covers usage-capture installation, and DIS-49 defines provider-selection shorthand. None of those establishes a trustworthy Claude message transport or proves how Codex-shaped operations map to Claude. + +Claude Code 2.1.210 advertises background agents, `--resume`, explicit session IDs, named sessions, realtime stream-JSON input/output, hook-event streaming, remote control, permission modes, and Agent View. zmx 0.6.0 provides persistent PTYs, raw input, history, tailing, and process lifetime, but `zmx send` is explicitly fire-and-forget. These are candidates to verify, not accepted architecture. + +## Research Questions + +### Session identity and lifecycle + +- What durable Claude identifiers exist for interactive sessions, background agents, Agent View entries, resumed sessions, forks, worktrees, remote-control sessions, and subprocesses? +- Can Dispatch choose or recover a session ID before first message delivery, and does that ID remain stable across detach, process restart, `--resume`, `--continue`, Agent View, and zmx reattachment? +- What metadata can be discovered without reading transcripts or private state, and which title/cwd/project/status fields are authoritative versus presentation-only? +- What do completion, failure, cancellation, permission wait, user-input wait, archive/retention, and process death look like? + +### Launch and transport + +- Compare Agent View/background dispatch, interactive PTY, `--resume`, stream-JSON print mode, remote control, zmx, and any supported direct CLI surface for persistent multi-turn control. +- Determine which candidate owns process lifetime, input framing, output framing, backpressure, reconnection, concurrent writers, terminal sizing, cancellation, and crash recovery. +- Prove whether a message sent from a separate process is merely written, accepted by Claude, committed as a user turn, or completed. Do not treat a successful shell exit, PTY write, or scrollback appearance as delivery proof. + +### Operation semantics + +For `new`, `attach`, `send`, `steer`, `queue`, `interject`, `context`, `stop`, `tail`, `watch`, `rename`, `archive`, `restore`, `goal`, permissions, and structured output, identify the exact Claude primitive, a safe Dispatch composition, or an explicit unsupported result. In particular: + +- Does input sent during an active turn steer that turn, queue for the next turn, get rejected, or behave inconsistently? +- Can interrupt plus input reliably implement interject without losing the session or creating duplicate turns? +- Is there any true model-visible context injection distinct from a user message? If not, do not relabel `send` as `context`. +- Can Dispatch's durable queue drain only when Claude is truly ready for input? +- Can a stopped/interrupted session accept another message without attach/resume ambiguity? + +### Hooks, receipts, and attention + +- Verify the supported schemas and ordering for `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, `Notification`, elicitation/user-input events, `Stop`, `SubagentStop`, and any Agent View or message-display events available in the installed version. +- Determine the earliest event that proves provider acceptance, the event that proves turn completion, and the events that mean human attention is required. +- Prove a correlation envelope using Claude session identity plus a Dispatch message ID without exposing markers to the model unnecessarily or allowing sender spoofing. +- Determine whether per-session `--settings` can add Dispatch hooks while preserving operator settings and whether hook failure can block or corrupt the session. + +### Architecture, security, and operations + +- Define the provider adapter boundary beneath authored Dispatch operations, including capability negotiation and typed unsupported errors. +- Define lane/provider identity, event normalization, message receipts, runtime state, inbox/attention, queue ownership, and recovery storage without duplicating provider-neutral tables. +- Threat-model PTY injection, shell quoting, control characters, spoofed attribution, hook command execution, settings precedence, transcript leakage, debug logs, concurrent sends, and stale process/session identity. +- Preserve a path to SSH/mesh operation without implementing remote transport in this goal. + +## Scope + +### In + +- Official Claude documentation, installed CLI behavior, Agent View/background agents, hooks, settings, stream JSON, resume/attach behavior, remote control, and zmx behavior. +- Read-only inspection of existing agent metadata when useful; all message/control experiments use disposable sessions in temporary repositories. +- Small reproducible probes, sanitized fixtures, capability matrices, sequence diagrams, research notes, ADRs where decisions are justified, and an implementation milestone plan. +- Scoped Linear reconciliation for DIS-9 and DIS-49 plus creation of focused implementation issues and dependency edges when the evidence supports them. +- Two independent local-review lanes: transport/protocol correctness and security/product semantics. + +### Out + +- Production Claude adapter code, public provider flags, schema migrations, release/publish, mesh transport, Slack/Linear gateway work, or broad provider plugin architecture. +- Messaging or interrupting existing user sessions, reading raw transcripts, reading auth files/keychain material, calling private endpoints, or modifying global/project Claude settings. +- Treating Claude Agent SDK behavior as proof of direct Claude CLI behavior. SDK material may be compared, but the intended implementation target is the supported Claude CLI/Agent View surface. + +## Acceptance Criteria + +- A source-backed capability matrix covers every operation and lifecycle question with one of: `verified`, `unsupported`, `product-decision`, or `blocked`, plus confidence, exact evidence, version, and next action. No naked `unknown` remains. +- A disposable walking skeleton proves, or precisely disproves, cross-process launch, durable identity, separate-process message delivery, hook-confirmed acceptance, completion, interruption, resume/attach, and a second message. +- Delivery acceptance and completion are proven without using raw zmx scrollback as the source of truth. +- At least one failure/recovery sequence covers process death or transport loss, duplicate/retried send, concurrent input, and permission or user-input attention. +- An implementation plan defines provider interfaces, operation capability mapping, storage/event contracts, process supervision, settings/hook injection, security boundaries, testing fixtures, rollout, and an ordered issue/PR sequence beginning with the smallest walking skeleton. +- Official documentation and local experiments are distinguished from inference. Contradictions are reproduced or recorded with source/version/date rather than silently resolved by preference. +- Research artifacts, RETRO evidence, focused reviews, `just check`, hosted CI, and relevant Linear issues are current before the research PR leaves draft. + +## Decisions + +- Completion horizon is `ready-pr`; this goal researches and plans but does not implement production Claude support. +- Use current supported direct Claude CLI surfaces first. zmx is a candidate process/PTY adapter, not a receipt authority. +- Use the cheapest suitable Claude model and minimal prompts for live probes; repeat behavior only enough to establish confidence. +- Existing sessions and live Claude configuration are read-only. Disposable test sessions must be named and cleaned up. +- Unsupported provider semantics are a valid result. Silent Codex fallback or pretending different semantics are equivalent is not. + +## Risks + +- Claude CLI and Agent View behavior may change faster than published docs; every conclusion needs a version and evidence source. +- Interactive terminal behavior can appear successful while losing, buffering, or reinterpreting input. +- Hooks can observe completion while still failing to prove initial provider acceptance unless correlation is designed carefully. +- Excessive live probing can consume tokens or leave orphan processes; use bounded prompts, explicit budgets where supported, and cleanup ledgers. +- A provider-neutral abstraction can erase important semantic differences; the plan must expose capability differences rather than force false parity. From 39c4c10c20ef496aff65c37d53a157f8c601d43b Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:04:00 -0400 Subject: [PATCH 2/9] docs: verify Claude session control --- .../RETRO.md | 195 +++-- .../0002-single-daemon-over-one-app-server.md | 10 +- .../0007-normalized-internal-lane-events.md | 28 +- ...23-provider-event-log-and-history-index.md | 30 +- ...control-uses-resume-processes-and-hooks.md | 133 ++++ docs/adrs/README.md | 1 + docs/development/claude-provider-plan.md | 680 ++++++++++++++++++ .../claude-control-plane-verification.md | 299 ++++++++ spikes/claude/README.md | 370 ++++++++++ spikes/claude/assert_probe.py | 180 +++++ spikes/claude/fake_process_tree.sh | 20 + spikes/claude/fake_repl.sh | 11 + .../claude/fixtures/aggregate-receipts.jsonl | 8 + .../negative/final-continuation.jsonl | 7 + .../fixtures/negative/nonzero-exit.jsonl | 7 + .../fixtures/negative/stop-exit-two.jsonl | 7 + .../fixtures/negative/truncated-hooks.jsonl | 6 + spikes/claude/fixtures/receipt-sequence.jsonl | 6 + .../fixtures/whitespace-block-raw.jsonl | 1 + spikes/claude/hook_block_prompt.sh | 3 + spikes/claude/hook_capture.sh | 30 + spikes/claude/hook_continue_once.sh | 8 + spikes/claude/hook_fail.sh | 3 + spikes/claude/hook_preflight.sh | 7 + spikes/claude/hook_timeout.sh | 4 + spikes/claude/make_settings.sh | 81 +++ spikes/claude/process_group_probe.py | 68 ++ spikes/claude/sanitize_stream.jq | 28 + 28 files changed, 2173 insertions(+), 58 deletions(-) create mode 100644 docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md create mode 100644 docs/development/claude-provider-plan.md create mode 100644 docs/research/claude-control-plane-verification.md create mode 100644 spikes/claude/README.md create mode 100644 spikes/claude/assert_probe.py create mode 100755 spikes/claude/fake_process_tree.sh create mode 100755 spikes/claude/fake_repl.sh create mode 100644 spikes/claude/fixtures/aggregate-receipts.jsonl create mode 100644 spikes/claude/fixtures/negative/final-continuation.jsonl create mode 100644 spikes/claude/fixtures/negative/nonzero-exit.jsonl create mode 100644 spikes/claude/fixtures/negative/stop-exit-two.jsonl create mode 100644 spikes/claude/fixtures/negative/truncated-hooks.jsonl create mode 100644 spikes/claude/fixtures/receipt-sequence.jsonl create mode 100644 spikes/claude/fixtures/whitespace-block-raw.jsonl create mode 100755 spikes/claude/hook_block_prompt.sh create mode 100755 spikes/claude/hook_capture.sh create mode 100755 spikes/claude/hook_continue_once.sh create mode 100755 spikes/claude/hook_fail.sh create mode 100755 spikes/claude/hook_preflight.sh create mode 100755 spikes/claude/hook_timeout.sh create mode 100755 spikes/claude/make_settings.sh create mode 100755 spikes/claude/process_group_probe.py create mode 100644 spikes/claude/sanitize_stream.jq diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index ca53ce1..4c04e00 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -1,8 +1,8 @@ # Execution Retro: Claude Control-Plane Research Date started: 2026-07-15 -Date finalized: pending -Status: Prepared for delegated execution +Date finalized: 2026-07-15 +Status: Executing baseline and supported-surface verification Spec: `.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md` Goal: `.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md` Prompt: `.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md` @@ -13,7 +13,7 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Objective: verify Claude session-control semantics and produce an implementation-ready Dispatch provider plan. - Completion horizon: `ready-pr`. - Authority: isolated low-cost research, scoped docs/spikes/ADRs/tracker, commit/push/draft/ready PR; no production implementation, merge, release, publish, live settings mutation, private endpoint, credential access, or existing-session messaging. -- Current state: packet prepared on `docs/claude-control-plane-research`; execution not started. +- Current state: delegated execution active on `docs/claude-control-plane-research`; baseline ledger complete and supported-surface audits in progress. - Known baseline: Dispatch 0.10.0 observes Claude but controls only Codex; Claude Code 2.1.210 exposes several candidate direct surfaces; zmx 0.6.0 raw send is fire-and-forget. ## Readiness @@ -48,7 +48,51 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` ## Execution Log -Execution has not started. Append chronological milestone, experiment, cleanup, tracker, and source-control evidence here. +```text +2026-07-15 - Delegated execution baseline +- Skill/callback: initialized as @Dispatch:ClaudeControlResearch with delegate-init; coordinator return ID is recorded in PROMPT.md. +- Repository: clean branch docs/claude-control-plane-research at 06ae5749e270358f86f4bff818d60f4ca976028c; no associated or open PR. +- Versions: dispatch 0.10.0; Claude Code 2.1.210; zmx 0.6.0. +- Live-state inventory (metadata only): 10 existing Agent View entries, 1 pre-existing zmx session, and 18 matching Claude processes. None were opened, messaged, interrupted, renamed, attached, or otherwise mutated. +- Settings guard: recorded SHA-256 and file metadata for user and repository-local Claude settings without printing contents. User settings hash starts 52a8b8fe; repository-local settings hash starts 378cd942; absent paths were recorded. +- Tracker: fetched DIS-9, DIS-48, and DIS-49 with relations. DIS-9 remains Todo and blocked by DIS-1/2/3; DIS-48 is adjacent usage capture; DIS-49 defines provider selection but explicitly excludes transport implementation. +- Source control: no mutation beyond this RETRO ledger update; no production implementation, release, publish, or existing-session action. +- Cleanup baseline: disposable prefix not yet allocated; zero research-created sessions/processes/temp repositories. + +2026-07-15 - Stream JSON and resume milestone +- Allocated one temporary Git repository under `/tmp/dispatch-claude-control-` with a per-invocation settings file and sanitized hook log. No live/project settings were modified. +- Created an explicit session UUID with print-mode stream JSON and a synthetic `DISPATCH-PROBE:m1` message. The result reported the chosen UUID and success; `UserPromptSubmit` captured the same UUID and marker. +- Resumed the same UUID from two fresh CLI processes with `m2` and `m3`. Both produced successful results; the corrected logger captured `SessionStart(source=resume)`, correlated `UserPromptSubmit(m3)`, `Stop`, and `SessionEnd` without prompt/model/transcript content. +- Surprise/failure: the first sanitizer used a jq `capture` expression that emitted no object when an event lacked a prompt, so only `UserPromptSubmit` was retained. This was a probe bug, not a Claude failure. Fixed the expression to emit null and reran; lifecycle events then appeared. +- Observed hook-event output includes multiple SessionStart/Stop hook executions because per-session hooks compose with existing lower-scope hooks. Only the Dispatch probe hook writes the sanitized probe log; implementation must dedupe normalized provider events and must not assume exclusive hook ownership. +- Interim verdict, later corrected by aggregate-hook probes: explicit Claude session UUID plus fresh-process `--resume` is a supported durable multi-turn composition. One Dispatch `UserPromptSubmit` observer is submission evidence only; one `Stop` is a repeatable cycle boundary, not completion alone. + +2026-07-15 - Lifecycle, concurrency, attention, failure, and zmx milestone +- Interrupt/recovery: started a resumed turn that invoked a 30-second Bash sleep, observed correlated `UserPromptSubmit` and tool hooks, then sent SIGINT to the owned CLI process. It exited 130 without a new `Stop`; a fresh process resumed the same UUID and completed a later message. This matches the documented rule that user interruption does not emit `Stop`. +- Concurrent sends: two simultaneous `--resume --print` processes both succeeded. Each `UserPromptSubmit` received a distinct provider `prompt_id`, and each `Stop` carried the matching `prompt_id`; submission order differed from launch order. Provider prompt IDs give exact cycle joins, but Dispatch must enforce a durable single-writer lease for ordered delivery. +- Duplicate send: two simultaneous processes carrying the same synthetic marker produced two distinct provider `prompt_id` values and two turns. Claude provides no idempotency key at this surface; ambiguous retries must reconcile receipts and never resend blindly. +- Hook failure: an added exit-1 `UserPromptSubmit` observability hook produced a hook response with exit code 1 while the Dispatch capture hook succeeded and the turn completed. A separate two-second hook with a one-second timeout was reported as `outcome=cancelled`, exit 1; the prompt still completed. Missing/failed/timed-out hook settlement means unknown processing, not rejection. +- Agent View attention: a named disposable `--bg` session returned short management ID `518b912b` and full UUID `518b912b-…`; its metadata reached `state=blocked`, `status=waiting`, `waitingFor=permission prompt`. Sanitized hooks joined `UserPromptSubmit`, `PermissionRequest(tool=AskUserQuestion)`, `Notification(type=permission_prompt)`, and `Stop` by one `prompt_id`. +- Agent View attach: `claude attach` accepted the synthetic answer, completed, detached with Ctrl-Z without stopping, then accepted a second attached prompt with a new `prompt_id` and matching `Stop`. The shell has lifecycle commands but no non-interactive reply command, so Agent View is a human supervision surface rather than the default Dispatch message transport. +- Agent View restart: a second disposable background session completed, was stopped (and disappeared from the default active JSON list), then `respawn` restored the same full UUID. Both background entries were stopped and removed; `--all`/cwd-filtered inventory returned zero matching rows. +- Fork: `--resume --fork-session` completed under a new UUID while retaining the source UUID, confirming a real branch primitive with separate identity. +- zmx fake target: in isolated mode-0700/0600 directories, concurrent raw sends reached the synthetic target in `b` then `a` order despite `a` being launched first. A Ctrl-C raw send returned success but did not prevent `completed:c`. After killing the session, a send printed an unresponsive-session error yet exited 0. The isolated zmx inventory returned to zero. +- zmx security blocker: tagged 0.6.0 source hard-codes debug logging and logs PTY input bytes in recoverable hex. Even private 0700/0600 directories retain prompt bytes. Production Claude-over-zmx is a product/security decision and is excluded from the recommended first transport until logging can be disabled/redacted in a pinned supported build. + +2026-07-15 - Aggregate receipts, transport/security review, and preflight milestone +- Transport review round 1 scored 2/5 with 3 P1 and 4 P2 findings. Security/product review round 1 scored 2/5 with 4 P1 and 2 P2 findings. Reports are local scratch under `tmp/reviews/transport/round-1.json` and `tmp/reviews/security-product/round-1.json`. +- Blocking sibling prompt hook: the Dispatch observer completed, a sibling `UserPromptSubmit` hook exited 2, no assistant activity or Stop occurred, yet the CLI emitted result subtype success. Therefore observer success and result success do not prove acceptance; processing requires aggregate prompt-hook settlement plus owned-stream assistant/tool activity. +- Continuing sibling Stop hook: one prompt produced two Stop occurrences with the same provider prompt ID, separated by further assistant activity. Completion requires the final Stop hook set to settle without continuation, terminal result success, and clean owned-process exit. +- Stream structure: `hook_started`/`hook_response` share CLI `hook_id`; raw hook payloads do not. Every ingest gets a daemon delivery ID, while repeated semantic Stop occurrences remain distinct. +- Retry correction: a retry is safe only when Dispatch proves no stdin frame write began. Any transport loss after a possible write is acceptance-indeterminate and blocks automatic drain. +- Interrupt correction: transport attempt state and provider completion state are separate. SIGINT after processing starts proves transport interruption only; provider completion remains unknown. Explicit operator abandonment may release Dispatch's queue while preserving that unknown fact. +- Trust correction: a per-generation nonce fences stale or misrouted Dispatch hook responses but is not spoof-resistant against same-UID hooks, repository code, or tools. Owned-stream corroboration, OS-user isolation, and Claude permission/sandbox policy are the boundary. +- Attach correction: Dispatch-owned `--resume`-for-send and human Agent View attach are verified separate capabilities. Writable registration of an unmanaged ordinary Claude UUID is unsupported in v1 because no content-free metadata validation primitive was proven. +- Privacy correction: removed durable prompt SHA-256 from the planned schema; the one-writer lease, session UUID, generation, and provider prompt ID correlate without adding a stable content fingerprint. +- No-message preflight: a disposable stream-input process with EOF emitted a successful Dispatch SessionStart hook response carrying the current nonce before any prompt frame. Sibling hook stdout was reduced immediately and not retained. This supports abort-before-write when managed settings suppress the hook channel. +- Process ownership fixture: `uv run python spikes/claude/process_group_probe.py` verified a POSIX `start_new_session` parent/child pgid and whole-group SIGINT/TERM cleanup. A live Claude descendant check remains an implementation scenario gate. +- Cleanup: review temp repo/settings removed, matching processes zero, settings hashes unchanged, and no existing session was touched. +``` ## Experiment Ledger @@ -56,44 +100,111 @@ Execution has not started. Append chronological milestone, experiment, cleanup, | --- | --- | --- | --- | --- | --- | --- | --- | | prep-1 | Does Dispatch currently control Claude sessions? | Trace public handlers and client protocol | Dispatch 0.10.0 | No; control path is Codex-only | high | SPEC/REFS | n/a | | prep-2 | Is zmx send a delivery receipt? | Inspect installed primary help | zmx 0.6.0 | No; raw input is fire-and-forget | high | REFS | n/a | +| base-1 | What supported candidate surfaces does the installed Claude CLI expose? | Bounded `claude --help` and `claude agents --help` inspection | Claude Code 2.1.210 | Background agents, Agent View JSON metadata, resume/continue/fork/session ID/name, stream JSON, hook-event output, remote control, per-session settings, permission modes, and worktrees are advertised | high | local help; research doc pending | read-only | +| base-2 | Can existing session inventory be bounded without transcript access? | `claude agents --json` reduced immediately to JSON type/count | Claude Code 2.1.210 | Yes; array shape with 10 entries. No entry bodies retained or printed | high | RETRO baseline | read-only | +| base-3 | What live-state guards exist before disposable experiments? | Count-only Agent View/zmx/process inventory plus settings file metadata and hashes | local baseline 2026-07-15 | Baseline captured; all research resources must use a unique prefix and return to zero | high | RETRO baseline | hashes unchanged; created inventories zero | +| life-1 | Can one durable UUID span independent processes and turns? | Explicit `--session-id`, then fresh `--resume` processes with synthetic markers | Claude Code 2.1.210 | Yes; one UUID across structurally completed turns | high | research doc + sanitized hooks | processes exited | +| life-2 | Can an interrupted turn resume? | Long tool turn, exact-process SIGINT, fresh resume | Claude Code 2.1.210 | Exit 130/no Stop; later same UUID completed | high | research doc | process exited | +| msg-1 | Are concurrent/duplicate sends ordered or idempotent? | Parallel resume processes with distinct and duplicate markers | Claude Code 2.1.210 | Both produced turns; order differed; duplicate got distinct prompt IDs | high | research doc | processes exited | +| hook-1 | Do hook failure/timeout reject a prompt? | Added exit-1 and timeout fixtures beside capture hook | Claude Code 2.1.210 | No; both fail open and turn completes | high | spike scripts + research doc | processes exited | +| attn-1 | Can attention and response be observed without transcript reads? | Disposable Agent View session asking a synthetic question | Claude Code 2.1.210 | JSON waiting state plus permission/notification hooks; attach response and Stop share prompt ID | high | research doc + fixture | stopped/removed | +| restart-1 | Does Agent View respawn preserve full identity? | Complete, stop, respawn, inspect metadata | Claude Code 2.1.210 | Same full UUID restored | high | RETRO/research doc | stopped/removed | +| zmx-1 | Does raw send prove delivery/order/interrupt? | Isolated fake REPL; parallel input, Ctrl-C, kill then send | zmx 0.6.0 | No; reordered, Ctrl-C unconfirmed, loss error exited zero | high | spike fake + research doc | killed; isolated count zero | +| hook-2 | Can one successful observer prove prompt acceptance? | Sibling capture + exit-2 prompt blocker | Claude Code 2.1.210 | No; observer and result success occurred without assistant/Stop | high | aggregate fixture + research doc | process exited | +| hook-3 | Does one Stop prove completion? | Sibling Stop hook continued exactly once | Claude Code 2.1.210 | No; two Stop cycles shared one prompt ID with assistant activity between | high | aggregate fixture + research doc | process exited | +| hook-4 | Can Dispatch prove hook presence before message write? | SessionStart nonce hook, stream input with immediate EOF | Claude Code 2.1.210 | Yes; owned hook response carried nonce before any user frame | high | preflight fixture + README | process exited; no prompt | +| proc-1 | Can a POSIX owner terminate an exact descendant group? | `start_new_session`, pid/pgid checks, SIGINT/TERM fake tree | local POSIX/Python | Yes; parent and child exited | high for primitive | executable spike | temp removed | + +## Source / Version Ledger + +| Source | Retrieved/version | Use | State | +| --- | --- | --- | --- | +| Claude CLI reference `/docs/en/cli-usage` | 2026-07-15 rolling docs + installed 2.1.210 help | print/stream, UUID, resume/fork/name, settings, permissions, structured output | reconciled | +| Claude sessions | 2026-07-15 rolling docs | scoping, resume/fork/name, concurrency, retention | reconciled with probes | +| Claude Agent View | 2026-07-15 research preview + installed 2.1.210 | supervisor, IDs/states, attach/stop/respawn/rm, worktrees | reconciled with probes | +| Claude hooks | 2026-07-15 rolling docs + installed events | receipt/attention schemas and failure semantics | reconciled with probes | +| Claude settings | 2026-07-15 rolling docs | precedence/merge and no-mutation strategy | verified by composition/hash guard | +| Claude Remote Control | 2026-07-15 research preview | explicit external product boundary | documented only; no live mutation | +| zmx docs and tagged v0.6.0 source | 2026-07-15 / 0.6.0 | PTY/send/security semantics | reconciled with isolated fake target | +| Dispatch source/ADRs | commit 06ae574 baseline | provider/storage/handler/selector seams | audited | + +## Cleanup Audit + +```text +2026-07-15 final experiment cleanup +- Agent View: both created short IDs stopped and removed; --all/cwd-filtered matching count = 0. +- zmx: isolated namespace matching count = 0; pre-existing default namespace count remained 1 and was untouched. +- Processes: matching disposable process count = 0. +- Temp repository/settings/logs: owned /tmp/dispatch-claude-control-9ae658fe-... root removed. +- Settings: user hash remained 52a8b8fe...; repository-local hash remained 378cd942...; sizes/mtimes and absent paths unchanged. +- Existing Agent View entries and default zmx session were never opened, read, messaged, attached, interrupted, renamed, stopped, or removed. +- Claude's documented local transcript retention remains provider-owned. The research did not locate, read, or manually delete transcript files; Agent View rm is not transcript delete. +``` ## Capability Matrix -The delegate must move every row to `verified`, `unsupported`, `product-decision`, or `blocked` and add evidence, confidence, failure/recovery, and implementation consequence. - -| Operation/capability | Status | Claude primitive/composition | Acceptance/completion evidence | Confidence | Next experiment | -| --- | --- | --- | --- | --- | --- | -| durable identity | pending | | | | | -| new | pending | | | | | -| attach/resume | pending | | | | | -| send | pending | | | | | -| steer during active turn | pending | | | | | -| durable queue/readiness | pending | | | | | -| interject | pending | | | | | -| context injection | pending | | | | | -| stop/interrupt | pending | | | | | -| tail/history | pending | | | | | -| watch/events | pending | | | | | -| rename | pending | | | | | -| archive/restore | pending | | | | | -| goal loop | pending | | | | | -| permissions/approval | pending | | | | | -| user input/elicitation | pending | | | | | -| structured output | pending | | | | | -| rich input/files/images | pending | | | | | -| process restart/recovery | pending | | | | | -| duplicate/concurrent send | pending | | | | | -| remote/mesh compatibility | pending | | | | | +No row remains pending or unknown. Full semantics and citations are in +`docs/research/claude-control-plane-verification.md`. + +| Operation/capability | Status | Primitive/composition | Preconditions | Acceptance/completion receipts | Failure/recovery and consequence | Evidence/version/confidence | +| --- | --- | --- | --- | --- | --- | --- | +| durable identity | verified | caller-chosen full UUID | correct project/worktree; provider-qualified route | same UUID in stream/hooks | typed stale/not-found; names/short IDs never authority | observed 2.1.210, high | +| new | verified | `--session-id UUID --print` | persist lane/message; hook preflight; no frame written | aggregate prompt settlement + activity; final settled Stop cycle + result/exit | retry only when no frame write began | observed 2.1.210, high | +| owned resume-for-send | verified | fresh Dispatch-owned `--resume UUID` | owned identity; correct cwd; no active attempt | `SessionStart(resume)` then aggregate receipts | typed stale/not-found; never `--continue` | observed/docs 2.1.210, high | +| human Agent View attach | verified | human `attach SHORT_ID` | known Agent View entry and operator TTY | UI plus ordinary hooks; not Dispatch transport | no shell reply RPC | observed 2.1.210, high | +| Dispatch attach unmanaged ordinary UUID | unsupported | none proven | n/a | none | do not grant writable authority from UUID alone | surface audit, high | +| send | verified | one serialized fresh resume process | owned identity; preflight; one-writer lease | processing after terminal non-blocking hook settlement/activity; completion after final Stop settlement/result/exit | possible-write loss is indeterminate; never auto-retry | observed 2.1.210, high | +| steer during active turn | unsupported | no documented print-process RPC | n/a | none | typed unsupported; do not relabel queue | official/local audit, high | +| durable queue/readiness | product-decision | Dispatch queue | explicit root allowlist; terminal receipt; healthy hooks; no attention/background work | next aggregate receipt sequence | uncertainty blocks drain until operator resolution | evidence high, policy DIS-52 | +| interject | product-decision | interrupt, audited abandon, then ordinary send | verified owned group; explicit abandon if processing possible | transport exit plus new message receipts | non-atomic; prior provider completion remains unknown | observed 2.1.210, high | +| context injection | unsupported | no safe Claude equivalent | n/a | none | typed unsupported; never convert silently to user text | surface audit, high | +| stop/interrupt | verified | SIGINT verified owned process group | matching pid/pgid/start identity/generation | process exit proves transport interruption only | after processing, completion stays unknown; explicit abandonment may release lease | observed 2.1.210 exit 130, high | +| tail/history | product-decision | normalized live events only for watch | explicit future transcript-retention policy for history | event cursor only | history default off | docs/audit, high | +| watch/events | verified | advisory hooks + owned stream | active owned generation; bounded schemas | hook/source IDs, session/prompt IDs, occurrence order | generation fence/replay dedupe; same-UID spoofing outside nonce guarantee | observed 2.1.210, high | +| rename | product-decision | startup name; human UI later | UUID remains authority | metadata only | no documented scriptable later rename | docs/help, medium | +| archive/restore | unsupported | Agent View rm is removal, not archive | n/a | none | transcript remains resumable; no parity claim | docs + observed cleanup, high | +| goal loop | product-decision | Dispatch goals may send ordinary turns | ordinary send capability | normal aggregate receipts | no forced provider goal-mutation parity | docs/audit, medium | +| permissions/approval | verified | permission mode + request hooks | owned generation; managed policy remains higher | request/decision plus later activity/cycles | failure stays attention/unknown; never implicit allow | observed AskUserQuestion, high | +| user input/elicitation | product-decision | attention hooks + human Agent View response | known Agent View entry and human | shared prompt ID plus later aggregate completion | first slice observes only | observed 2.1.210, high | +| structured output | verified | print `--json-schema` | opt-in request/schema | validated result plus aggregate completion | typed schema/provider failure | official/help 2.1.210, medium-high | +| rich input/files/images | product-decision | provider resources/human paste | future local input contract | ordinary aggregate receipts | first slice text-only | official/help, medium | +| process restart/recovery | verified | fresh resume; Agent View respawn | persisted UUID/generation; no blind replay | resume lifecycle and later receipts | provider-partitioned recovery only | observed 2.1.210, high | +| duplicate/concurrent send | verified | provider creates independent turns | absent Dispatch one-writer/dedupe controls | distinct prompt IDs and receipt cycles | ambiguous retry duplicates; serialize before spawn | observed 2.1.210, high | +| remote/mesh compatibility | product-decision | owning daemon executes; Remote Control separate | explicit external policy/config | owner receipts if enabled | relay auth/outage semantics require decision | official docs, high | ## Review Log | Round | Scope | Report | Score | State | Open P0-P2 | Notes | | --- | --- | --- | --- | --- | --- | --- | +| 1 | transport | `tmp/review-history/transport/round-1.json` | 2/5 | changes requested, fixes applied pending rerun | 7 at review time | aggregate receipts, retry, interrupt state, event identity, pgid, preflight, reproducibility | +| 1 | security/product | `tmp/review-history/security-product/round-1.json` | 2/5 | changes requested, fixes applied pending rerun | 6 at review time | same-UID trust, attach split, prompt digest removal, cleanup contract | +| 2 | transport | `tmp/review-history/transport/round-2.json` | 3/5 | changes requested, fixes applied pending rerun | 4 at review time | ADR-0023, interject, replay identity, mechanical probes | +| 2 | security/product | `tmp/review-history/security-product/round-2.json` | 3/5 | changes requested, fixes applied pending rerun | 4 at review time | cleanup bound, fail-open settlement, queue trust input, README command | +| 1 | full stack | `tmp/review-history/full-stack/round-1.json` | 2/5 | changes requested, fixes applied pending rerun | 6 at review time | ADR, migration, reactor ingress, issues, settings generator, RETRO | +| 3 | security/product | `tmp/reviews/security-product/round-3.json` | 5/5 | clean | 0 | all prior security/product findings fixed | +| 3 | transport | `tmp/review-history/transport/round-3.json` | 4/5 | changes requested, fixed in later probes | 1 | mechanical end-to-end assertions | +| 2 | full stack | `tmp/review-history/full-stack/round-2.json` | 3/5 | changes requested, fixed | 4 | accepted ADR, concrete persistence, selector ordering, preflight variable | +| 4 | transport | `tmp/review-history/transport/round-4.json` | 4/5 | changes requested, fixed | 1 | aggregate settlement and process-exit assertions | +| 3 | full stack | `tmp/reviews/full-stack/round-3.json` | 5/5 | clean | 0 | all prior full-stack findings fixed; live Linear reconciled | +| 5 | transport | `tmp/review-history/transport/round-5.json` | 4/5 | changes requested, fixed | 1 | structured block parsing and Stop exit-2 rejection | +| 6 | transport | `tmp/reviews/transport/round-6.json` | 5/5 | clean | 0 | all TR-001 through TR-010 fixed | ## Verification Log | Check | Scope | Result | Notes | | --- | --- | --- | --- | +| packet prompt | `check-goal-prompt --no-placeholders` | passed | 3,994/4,000 characters | +| packet doctor | goal packet | expected review blockers before final reruns | packet structure otherwise valid | +| shell fixtures | all `spikes/claude/*.sh` | passed | `sh -n` | +| JSON fixtures/settings | tracked JSONL plus all generated settings modes | passed | jq parse; settings mode 0600 | +| process group | `uv run python spikes/claude/process_group_probe.py` | passed | parent/child pgid and cleanup | +| spike lint | `uv run ruff check spikes/claude/process_group_probe.py` | passed | import/noqa fixes applied | +| probe assertions | aggregate replay + three negative completion fixtures | passed | hook pairing, continuation, and nonzero-exit failures enforced | +| repository gate attempt 1 | `just check` | 691 passed, 1 timing failure | focused supervisor test immediately passed; unrelated tracked code unchanged | +| repository gate attempt 2 | `just check` | 692 tests passed; package-content step raced | concurrent review gate rebuilt `dist`; standalone package check passed | +| repository gate | `just check` | full-stack reviewer passed | 692 tests plus build/package; final local rerun pending after transport review | +| final packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; review reports all 5/5 clean | +| final repository gate | `just check` | passed | Ruff, format, strict mypy, 692 passed/17 deselected, wheel/sdist and contents | ## Prompt / Goal Alignment @@ -104,18 +215,22 @@ The delegate must move every row to `verified`, `unsupported`, `product-decision | Item | State | Notes | | --- | --- | --- | -| DIS-9 | Todo | Existing Claude hooks/provider-events research issue | +| DIS-9 | In Progress | Description corrected to aggregate receipt findings; implementation order comment added; close only after ready research PR | | DIS-48 | Todo | Adjacent usage-capture lifecycle; not messaging transport | -| DIS-49 | Todo | Provider selector and CLI shorthands; transport out of scope | +| DIS-49 | Todo | Provider selector/CLI shorthand issue retained; blocked by DIS-50 and related to later slices | +| DIS-50 | Backlog | High-priority vertical Claude walking skeleton; blocked by DIS-1/2/3 | +| DIS-51 | Backlog | High-priority receipt reduction and generation recovery hardening; blocked by DIS-50 | +| DIS-52 | Backlog | High-priority queue, attention, and destructive indeterminate-attempt resolution; blocked by DIS-50/51 | +| DIS-53 | Backlog | Metadata-only Agent View evaluation; blocked by DIS-50/52; never grants authority | ## Final State -- Completion proof: pending execution. -- Walking-skeleton proof: pending. -- Capability verdict: pending. -- Implementation plan: pending. -- Review summary: pending. -- Verification summary: pending. -- Cleanup audit: pending. -- Remaining product decisions/blockers: pending. -- Recommended first implementation slice: pending. +- Completion proof: durable UUID, cross-process send, aggregate prompt receipt, repeated Stop cycles, interrupt/resume, attention, second message, and preflight verified in disposable sessions. +- Walking-skeleton proof: control primitive verified; production implementation intentionally not present. +- Capability verdict: every row resolved as verified, unsupported, or product-decision; no naked unknowns. +- Implementation plan: provider boundary, additive identity migration, event ingress, receipts, supervision, settings, recovery, security, surfaces, tests, rollout, and issues settled. +- Review summary: transport round 6, security/product round 3, and full-stack round 3 are each 5/5 clean with zero P0-P2. +- Verification summary: packet, fixture, lint/type, sanitizer privacy, process-group, negative receipt checks, and final local `just check` pass; PR CI pending. +- Cleanup audit: research-created Agent View/zmx/process/temp resources removed; settings hashes unchanged; provider-retained transcripts not read or manually deleted. +- Remaining product decisions/blockers: queue/attention (DIS-52), Agent View metadata (DIS-53), selector surface (DIS-49), and optional capabilities; none block an honest DIS-50 plan. +- Recommended first implementation slice: DIS-50 vertical walking skeleton behind disabled config; no abstraction-only precursor. diff --git a/docs/adrs/0002-single-daemon-over-one-app-server.md b/docs/adrs/0002-single-daemon-over-one-app-server.md index 3bdca2e..7fd6f80 100644 --- a/docs/adrs/0002-single-daemon-over-one-app-server.md +++ b/docs/adrs/0002-single-daemon-over-one-app-server.md @@ -4,7 +4,7 @@ slug: single-daemon-over-one-app-server title: Single Daemon over One App Server status: accepted created: 2026-06-02 -updated: 2026-06-09 +updated: 2026-07-15 owners: ['[galligan](https://github.com/galligan)'] --- @@ -20,6 +20,12 @@ Run one long-lived **daemon** (`dispatchd`) that spawns and owns **one** `codex We drive the App Server binary directly; the `openai-codex` SDK has lagged the installed CLI before, so adopting it would require a fresh bundled-binary check. +This decision governs the Codex runtime, not the number of execution providers. +`dispatchd` remains the single operation/control authority, while a fixed +provider manager may also own provider-specific runtimes such as the Claude +resume-process supervisor in ADR-0026. Claude sessions never go through or spawn +another Codex App Server. + ## Consequences ### Positive @@ -40,4 +46,4 @@ We drive the App Server binary directly; the `openai-codex` SDK has lagged the i ## References -- `docs/development/design.md`; `docs/research/app-server-verification.md`; `.claude/rules/client.md` +- `docs/development/design.md`; `docs/research/app-server-verification.md`; `.claude/rules/client.md`; [ADR-0026](0026-claude-control-uses-resume-processes-and-hooks.md) diff --git a/docs/adrs/0007-normalized-internal-lane-events.md b/docs/adrs/0007-normalized-internal-lane-events.md index edae376..7b374cf 100644 --- a/docs/adrs/0007-normalized-internal-lane-events.md +++ b/docs/adrs/0007-normalized-internal-lane-events.md @@ -4,7 +4,7 @@ slug: normalized-internal-lane-events title: Normalized Internal LaneEvent Vocabulary status: accepted created: 2026-06-02 -updated: 2026-06-02 +updated: 2026-07-15 owners: ['[galligan](https://github.com/galligan)'] --- @@ -16,7 +16,17 @@ The reactor and triggers must respond to lane activity. The raw App Server notif ## Decision -The **client layer** projects raw App Server notifications into a typed internal `LaneEvent` union — the single translation point. The reactor, triggers, and the conditional-guard seam operate **only** on `LaneEvent`s, never on raw protocol dicts. Initial vocabulary (extend as needed): +Each provider runtime projects its raw protocol into a typed +`ProviderEventEnvelope`; this provider-adapter boundary is the single raw-protocol +translation point. The reactor consumes the merged provider stream, resolves +provider identity to the Dispatch-local lane key, persists/reduces the envelope, +and publishes the typed internal `LaneEvent` union. Triggers and the +conditional-guard seam still operate **only** on `LaneEvent`s, never on raw +protocol dicts or raw hook payloads. Codex's client/router is its provider +projector; Claude's projector aggregates owned stream and hook observations +before it emits an envelope. + +Initial `LaneEvent` vocabulary (extend as needed): - `TurnStarted`, `TurnCompleted`, `TurnFailed` - `LaneIdle` (derived from status → idle) @@ -24,16 +34,22 @@ The **client layer** projects raw App Server notifications into a typed internal - `ItemCompleted`, `DiffUpdated` - `StatusChanged`, `TokenUsageUpdated` -Each carries lane id, turn id where applicable, and a typed payload. +Each carries the Dispatch-local lane key, turn id where applicable, and a typed +payload. Provider identity remains available on the persisted envelope and may be +included in typed payloads where consumers need it. ## Assumptions -- This vocabulary covers v1 trigger needs (`idle_for`, `turn_completed`, `waiting_on_approval`); new event types are added at the client boundary as needs appear. -- Derived events (e.g. `LaneIdle`) are computed in one place (the client/router), not re-derived per consumer. +- This vocabulary covers v1 trigger needs (`idle_for`, `turn_completed`, + `waiting_on_approval`); new event types are added at the provider-adapter / + reactor boundary as needs appear. +- Derived events (e.g. `LaneIdle`) are computed once in the provider-neutral + reactor/reducer, not re-derived per consumer. ## Consequences -- Triggers stay stable across App Server protocol drift; only the client's projection changes. +- Triggers stay stable across provider protocol drift; only that provider's + projector changes. - Guards and (future) conditional triggers are expressed against a clean, typed model. ## Alternatives considered diff --git a/docs/adrs/0023-provider-event-log-and-history-index.md b/docs/adrs/0023-provider-event-log-and-history-index.md index e961e99..c94066d 100644 --- a/docs/adrs/0023-provider-event-log-and-history-index.md +++ b/docs/adrs/0023-provider-event-log-and-history-index.md @@ -4,7 +4,7 @@ slug: provider-event-log-and-history-index title: Provider Event Log and History Index status: proposed created: 2026-07-01 -updated: 2026-07-11 +updated: 2026-07-15 owners: ['[galligan](https://github.com/galligan)'] --- @@ -36,12 +36,17 @@ This is a useful v0 compromise, but it leaves important product goals awkward: - future semantic search over thread history, summaries, tool results, and operator notes. -Recent Claude hook research also points at the same shape. Claude can emit +Direct Claude Code 2.1.210 verification now confirms the same shape. Claude emits structured lifecycle and attention events such as `UserPromptSubmit`, `Notification`, `PermissionRequest`, `Elicitation`, `ElicitationResult`, `Stop`, -`StopFailure`, `SessionStart`, and `SessionEnd`. `UserPromptSubmit` can act as a -delivery-acceptance receipt when correlated with a Dispatch message marker, and -`Stop` can act as a turn-completion signal. Those events are not Codex +`StopFailure`, `SessionStart`, and `SessionEnd`. A Dispatch `UserPromptSubmit` +observation is submission evidence only. Processing requires every sibling +prompt hook to reach a terminal non-blocking outcome plus first owned-stream +assistant/tool activity. Exit 1 and timeout are fail-open, degraded-health +outcomes; exit 2 or a blocking decision prevents processing. Claude assigns a +`prompt_id` shared by `UserPromptSubmit` and repeatable `Stop` cycles. Completion +requires the final Stop hook set to settle without continuation, followed by +terminal result success and clean owned-process exit. Those events are not Codex `LaneEvent`s, but they map naturally onto provider-neutral event and history records. @@ -118,10 +123,14 @@ Claude comes second: 1. Dispatch-created Claude sessions add per-session hooks where possible rather than mutating global Claude settings. 2. Claude hook events are ingested into `provider_events`. -3. Hook events are reduced into the same `message_receipts`, +3. Hook observations are combined with owned structured stream events before + reduction into the same `message_receipts`, `lane_runtime_state`, and attention/inbox semantics used for Codex. -4. Claude-specific transport details such as zmx remain provider adapter details, - not cross-provider history schema. +4. The initial transport is a serialized fresh resume/print subprocess per turn; + Claude-specific process details remain adapter-local, not cross-provider + history schema. +5. zmx 0.6.0 is excluded from the initial transport because raw sends are not + acknowledged and the installed version always logs PTY input bytes. Storage backend policy: @@ -165,8 +174,8 @@ Retention and privacy policy: on the design. - CLI, MCP, triggers, inbox, subscriptions, and future remote surfaces can read the same derived state. -- Message receipts can distinguish "transport accepted input", "provider accepted - prompt", and "turn completed". +- Message receipts can distinguish possible transport submission, observed + submission, provider processing, Stop cycles, and structurally completed turns. - `needs-attention` can become a normalized state across Codex approvals and Claude permission/elicitation hooks. - History search can become fast, scriptable, and filterable by stable fields. @@ -217,6 +226,7 @@ Retention and privacy policy: - [ADR-0021: Lane Inbox and Delivery](0021-lane-inbox-and-delivery.md) - [ADR-0022: Event Subscriptions](0022-event-subscriptions.md) - [Claude Code hooks reference](https://code.claude.com/docs/en/hooks) +- [ADR-0026: Claude Control Uses Resume Processes and Hooks](0026-claude-control-uses-resume-processes-and-hooks.md) - [Turso AI and embeddings](https://docs.turso.tech/features/ai-and-embeddings) - [Turso libSQL overview](https://docs.turso.tech/libsql) - [Turso Python quickstart](https://docs.turso.tech/sdk/python/quickstart) diff --git a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md new file mode 100644 index 0000000..600bd09 --- /dev/null +++ b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md @@ -0,0 +1,133 @@ +--- +id: 0026 +slug: claude-control-uses-resume-processes-and-hooks +title: Claude Control Uses Resume Processes and Hooks +status: proposed +created: 2026-07-15 +updated: 2026-07-15 +owners: ['[galligan](https://github.com/galligan)'] +--- + +# ADR-0026: Claude Control Uses Resume Processes and Hooks + +## Context + +Dispatch's operation, event, receipt, queue, and history substrate is becoming +provider-neutral, but its control runtime still calls one Codex App Server +client directly. Claude Code 2.1.210 exposes several possible control paths: +print/stream JSON, durable UUID resume, Agent View/background sessions, Remote +Control, hooks, interactive PTYs, and zmx. + +Disposable experiments proved that a caller-chosen UUID survives fresh-process +resume; `UserPromptSubmit` and `Stop` share a provider `prompt_id`; an owned +process can be interrupted and the UUID resumed; and concurrent/duplicate resume +processes create independent turns. Agent View provides durable human +supervision but no documented non-interactive shell reply. zmx 0.6.0 raw send is +unacknowledged, can exit zero after loss, and always logs PTY input bytes. + +## Decision + +Implement the first Claude runtime as one serialized, owned +`claude --resume --print` subprocess per turn. New sessions use an +explicit `--session-id `. Dispatch persists identity and the message +envelope before spawn and never has more than one in-flight process per Claude +session. + +Add Dispatch receipt/attention hooks through a generated per-invocation +`--settings` file. Do not modify user or project settings, and do not replace or +assume exclusive ownership of existing hooks. + +Receipt authority is aggregate: + +- Dispatch `UserPromptSubmit` observation -> submission observed only; +- all prompt hooks settled successfully plus first owned-stream assistant/tool + activity -> processing started; +- each `Stop` with the same provider `prompt_id` -> one stop cycle observed; +- final Stop hook settlement without continuation, then terminal result success + and clean owned-process exit -> main response completed; +- `StopFailure` -> provider/API failure; +- owned process SIGINT/exit without `Stop` -> interrupted or completion unknown, + never completed. + +Hook failure/timeout is fail-open in Claude. Missing aggregate evidence is +therefore unknown, not rejection. A sibling prompt hook can block after the +Dispatch observer succeeds, and a sibling Stop hook can continue a turn after a +Stop observation. Process exit, stdin write, stream replay, +terminal output, Agent View logs, zmx status, and scrollback are not receipts. + +Keep Agent View as an optional metadata/human-supervision integration. Exclude +zmx 0.6.0 and Remote Control from the initial production transport. Reconsider +either only through a separate decision with current evidence and explicit +security/product acceptance. + +Human Agent View attach is distinct from Dispatch-owned `--resume`-for-send. +Unmanaged ordinary-session attach is unsupported in the first adapter because no +content-free metadata validation primitive was proven. + +Preflight the Dispatch SessionStart hook response before writing stdin. Retry is +allowed only when no prompt frame write began. Any loss after a possible write is +indeterminate and blocks automatic queue drain until the operator waits for later +evidence or explicitly abandons the Dispatch attempt. Explicit abandonment does +not rewrite unknown provider completion as failure. + +Use generation nonces and owner-only sockets/directories to fence stale or +misrouted hook events, but do not claim spoof resistance against same-UID hooks, +repository code, or tools. Owned stream structure plus aggregate settlement is +required corroboration; the OS user and Claude permission/sandbox policy remain +the security boundary. + +Place a fixed provider manager under authored operations. Codex continues to use +the existing App Server runtime; Claude has its own process supervisor. Provider +capabilities are explicit and intersected with lane authority and runtime health. +There is no silent fallback or forced Codex semantic parity. + +## Consequences + +### Positive + +- Uses supported direct CLI primitives without private endpoints or a persistent + PTY dependency. +- Durable UUID and provider prompt ID give stable routing and exact cycle joins. +- Fresh processes simplify ownership, interrupt, cleanup, and daemon restart. +- Hooks compose with operator settings and feed the existing provider event / + receipt / attention substrate. +- Single-writer ownership prevents the observed interleaving and duplicate-turn + failure mode. + +### Tradeoffs + +- Each turn pays process startup cost. +- There is no true active-turn steer or hidden context injection in the first + adapter. +- Hook failure can leave processing uncertain even when Claude continues. +- Possible-write or processing-but-incomplete loss cannot be retried automatically. +- Human input waits are observable before a scriptable response path exists. +- Claude retains local session transcripts under its own retention policy; the + supported Agent View `rm` operation is not transcript deletion. + +## Alternatives considered + +- **Persistent interactive PTY through zmx** — rejected for v1: no delivery ACK, + silent loss/exit behavior, no idempotency/correlation, and mandatory raw-input + logging in 0.6.0. +- **Agent View as the primary transport** — rejected for v1: strong supervisor + and human UI, but no documented scriptable shell reply operation. +- **Remote Control** — deferred: it introduces Anthropic relay, subscription, + policy, availability, and multi-device semantics without a documented local + RPC for Dispatch. +- **Claude Agent SDK** — rejected as proof/target: this plan is for the supported + direct CLI/Agent View surface and must not infer CLI semantics from the SDK. +- **One generic provider plugin framework first** — rejected: two known runtimes + need a small fixed boundary and vertical slice, not speculative extensibility. +- **Treat process/result success as completion** — rejected: it cannot prove + provider acceptance and fails under interruption, hook loss, and zmx loss. + +## References + +- [Claude control-plane verification](../research/claude-control-plane-verification.md) +- [Claude provider implementation plan](../development/claude-provider-plan.md) +- [ADR-0002](0002-single-daemon-over-one-app-server.md) +- [ADR-0006](0006-handler-context-and-di.md) +- [ADR-0007](0007-normalized-internal-lane-events.md) +- [ADR-0023](0023-provider-event-log-and-history-index.md) +- [ADR-0024](0024-provider-thread-topology-is-independent-of-lane-authority.md) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 0cac9d5..fa30886 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -34,3 +34,4 @@ Files are `NNNN-slug.md`. Copy [`template.md`](template.md) to start one. Keep t | [0023](0023-provider-event-log-and-history-index.md) | Provider Event Log and History Index | Proposed | | [0024](0024-provider-thread-topology-is-independent-of-lane-authority.md) | Provider Thread Topology Is Independent of Lane Authority | Accepted | | [0025](0025-provider-capacity-observations-are-latest-value-snapshots.md) | Provider Capacity Observations Are Latest-Value Snapshots | Accepted | +| [0026](0026-claude-control-uses-resume-processes-and-hooks.md) | Claude Control Uses Resume Processes and Hooks | Proposed | diff --git a/docs/development/claude-provider-plan.md b/docs/development/claude-provider-plan.md new file mode 100644 index 0000000..3f9cd55 --- /dev/null +++ b/docs/development/claude-provider-plan.md @@ -0,0 +1,680 @@ +# Claude provider implementation plan + +Status: implementation-ready research plan; no production implementation +Evidence: [`claude-control-plane-verification.md`](../research/claude-control-plane-verification.md) +Decision: [ADR-0026](../adrs/0026-claude-control-uses-resume-processes-and-hooks.md) + +## Outcome + +Add Claude as a fixed second execution provider beneath Dispatch's authored ops. +The first implementation is a vertical walking skeleton: one Claude lane can be +created with a chosen UUID, receive serialized messages through fresh resume +processes, persist structurally corroborated processing/completion receipts, be interrupted, +survive a Dispatch restart, and receive another message. + +This is not a provider plugin framework. Codex remains the default. Unsupported +Claude semantics are visible capabilities and typed errors, never Codex fallback +or renamed approximations. + +## Decisions already settled + +- One `dispatchd` remains the control authority. It owns a fixed provider manager + with Codex and Claude runtimes. +- Codex continues to use the existing single App Server client. Claude does not + go through App Server. +- Claude v1 transport is one serialized `claude --resume --print` + subprocess per turn. New sessions use `--session-id `. +- Per-invocation `--settings` adds Dispatch hooks without modifying global or + project settings. Existing hooks continue to run. +- A `UserPromptSubmit` observation is only submission evidence. Processing is + confirmed after every prompt hook settles successfully and the owned CLI + stream emits the first assistant/tool activity for that prompt. +- A `Stop` observation is only a stop-cycle boundary. Completion requires the + final Stop hook set to settle without a continuation, followed by a terminal + success result and clean owned-process exit with no intervening activity. +- Process/stdin success, stream replay, result text, Agent View logs, PTY + scrollback, and zmx status are diagnostic only. +- zmx 0.6.0 is not a production transport because raw send is unacknowledged and + raw PTY input is always logged. +- Agent View is an optional human supervision/attention surface, not the first + programmatic send transport. +- One provider session has one in-flight Dispatch send. Concurrency is rejected + or queued before spawning a process. +- Raw hook payloads and transcripts are not retained by default. +- Mesh remains daemon federation. A remote request executes at the daemon that + owns the Claude runtime/session. + +## Provider boundary + +Replace direct handler dependence on the Codex-shaped `Ctx.client` with a small +fixed runtime manager. Keep App Server-only methods inside the Codex adapter. + +```python +class ProviderRuntime(Protocol): + provider: ProviderId + + def capabilities( + self, session: ProviderSession | None + ) -> ProviderCapabilities: ... + + async def start(self, request: StartRequest) -> ProviderSession: ... + async def send(self, request: MessageEnvelope) -> TransportAttempt: ... + async def interrupt(self, request: InterruptRequest) -> InterruptAttempt: ... + async def recover( + self, sessions: Sequence[ProviderSession] + ) -> RecoveryReport: ... + def events(self) -> AsyncIterator[ProviderEventEnvelope]: ... + async def close(self) -> None: ... + + +class ProviderManager(Protocol): + def runtime(self, provider: ProviderId) -> ProviderRuntime: ... + def capabilities( + self, provider: ProviderId, session: ProviderSession | None + ) -> ProviderCapabilities: ... +``` + +Do not put `context`, `steer`, goals, rename, archive, rollback, compact, or +history into the required protocol. Those are optional capabilities implemented +by a provider-specific extension or rejected with +`UnsupportedProviderOperationError`. + +`Ctx` receives `providers`, while a compatibility property may expose the Codex +client temporarily to narrow migration. New/changed handlers must resolve a +provider runtime before acting. No surface imports a provider runtime. + +### Common request and event envelopes + +```python +@dataclass(frozen=True) +class ProviderIdentity: + provider: Literal["codex", "claude"] + provider_session_id: str + + +@dataclass(frozen=True) +class MessageEnvelope: + dispatch_message_id: UUID + lane_id: UUID + identity: ProviderIdentity + attempt: int + text: str + process_generation: UUID + + +@dataclass(frozen=True) +class ProviderEventEnvelope: + provider: ProviderId + provider_session_id: str + process_generation: UUID + event_type: str + source_delivery_id: UUID + ingest_id: UUID + hook_id: str | None + provider_prompt_id: str | None + provider_timestamp: datetime | None + received_at: datetime + normalized: Mapping[str, JsonScalar] +``` + +The text exists only in the outbound message/queue record under the current +product contract. Hook ingestion receives bounded normalized facts, not a +durable prompt digest or raw prompt/tool/message content. The helper generates one +`source_delivery_id` per invocation and reuses it for bounded socket retries; the +daemon assigns a separate `ingest_id` to each arrival. Claude's owned stream `hook_id` +pairs hook starts/responses but is not present in the raw hook payload. + +### Provider event ingress + +`ProviderEventEnvelope` is the provider-neutral reactor input; it generalizes the +current Codex-shaped `LaneEvent` rather than creating a parallel reducer. Each +runtime emits envelopes into a merged `ProviderManager.events()` stream. The +reactor resolves `(provider, provider_session_id)` to the local lane key, persists +the provider event, runs the common receipt/runtime/attention reducer, and then +publishes a compatibility `LaneEvent` carrying the local lane key to existing +subscriptions and triggers. Codex's current event indexer becomes the Codex +projector into this envelope. Claude's runtime aggregator emits only after joining +helper observations with owned stream hook/activity/result events. No handler or +surface consumes raw Claude hook input. + +## Capability negotiation + +Current `writable`-derived booleans falsely imply that every owned lane supports +Codex operations. Replace them with the intersection of: + +1. provider support; +2. current session/process state; +3. local lane authority; +4. transport and hook health. + +Each capability reports: + +```json +{ + "supported": true, + "available_now": false, + "reason": "message_in_flight" +} +``` + +Reasons are a bounded enum such as `provider_unsupported`, `attached_read_only`, +`message_in_flight`, `needs_attention`, `hook_unhealthy`, `stale_identity`, or +`provider_unavailable`. CLI and MCP derive the same structure. + +Initial Claude capabilities: + +- supported: new, owned resume/send, interrupt, live watch, permission observation, + structured print output; +- composed/product-gated: queue, interject, history/tail, rename, user-input + response, rich input, remote control; +- unsupported: active-turn steer, Codex context injection, archive/restore, + Codex goal mutation. + +## Identity and registry migration + +The existing `lanes.id` is both Dispatch identity and Codex thread ID. Split +those concepts before routing Claude. + +### Schema changes + +- Keep the physical `lanes.id` column and all existing foreign keys unchanged; + reinterpret it as the stable Dispatch-local lane key. Existing values remain + valid local keys, so SQLite does not need a risky primary-key/table rebuild. +- Add `lane_provider_identities(lane_id TEXT PRIMARY KEY REFERENCES lanes(id) ON + DELETE CASCADE, provider TEXT NOT NULL CHECK(provider IN ('codex','claude')), + provider_session_id TEXT NOT NULL, UNIQUE(provider, provider_session_id))`. +- In one `BEGIN IMMEDIATE` migration, create the table, backfill every existing + lane as `('codex', lanes.id)`, verify row counts and uniqueness, then commit. + Any failed assertion rolls back the entire migration. +- New lanes allocate a random local `lanes.id` and insert the provider identity in + the same transaction before any provider mutation. All child tables continue to + reference that local key without rebuild or dual-write. +- Public outputs add `provider`, `provider_session_id`, and `lane_key`. The legacy + `id`/`lane` fields continue to expose `lanes.id`; for existing Codex rows this is + unchanged, while callers must use `provider_session_id` for provider identity. + Contract examples and one release-note deprecation remove the old "full Codex + thread ID" promise before any future field rename. +- Allocate refs with provider-specific source/payload. Never hash a Claude UUID + through `codex_ref_payload()`. +- Qualify raw provider selectors (`claude:`, `codex:`). An + unqualified managed ref remains safe; an unqualified unmanaged provider ID is + rejected once multiple providers are enabled. + +`provider_threads` already stores provider topology independently of lane +authority. Reuse it for metadata discovery; discovery never creates a writable +lane. + +Rollback before provider rows exist drops only `lane_provider_identities`. After +Claude rows exist, rollback disables Claude launches and retains the additive +table/read compatibility; it never deletes lanes or provider identities. Migration +tests start from the prior schema with lanes plus every child-table relationship, +exercise upgrade and failed-assertion rollback, and verify foreign keys/public +Codex outputs byte-for-byte. + +### Runtime state additions + +DIS-50 adds three focused tables; it does not put process-manager state into the +provider-neutral `lane_runtime_state` reducer row: + +- `provider_runtime_sessions`: one row per lane, keyed by `lane_id`, with provider, + current generation, pid/pgid/start identity, effective cwd, active attempt ID, + readiness/confidence, hook health/last timestamp, and recovery state/reason. +- `provider_transport_attempts`: append-only attempts keyed by random `attempt_id`, + with unique `(lane_id, dispatch_message_id, attempt_number)`, generation, + frame-write state, transport state, provider-message state, prompt ID, Stop + occurrence, hook settlement, uncertainty reason, terminal/result/exit facts, + and optional audited resolution actor/time/reason. A partial unique index permits + only one nonterminal attempt per lane. +- `provider_runtime_artifacts`: generation/path ownership and the cleanup state + machine defined below, keyed by `(lane_id, generation, path)`. + +Foreign keys target the local lane key with cascade only for explicit lane +deletion. DIS-50 owns creation, migration, idempotent request lookup, basic +monotonic transitions, restart recovery, and uncertainty. DIS-51 hardens replay, +late/out-of-order event reduction, StopFailure, indexes, and fixture breadth; it +does not invent or replace the attempt/session tables. + +## Claude runtime and supervision + +### Launch + +For `new --provider claude`: + +1. validate provider availability/version and requested capabilities; +2. allocate `lane_key`, ref, full Claude UUID, and process generation; +3. persist lane/runtime/message state before spawning; +4. create an owner-only runtime directory and generated settings file atomically; +5. spawn with `asyncio.create_subprocess_exec`, an explicit argv, bounded env, + cwd, `--session-id UUID`, `--print`, stream JSON, Haiku/default model as + configured, permission mode, and settings path; +6. write one framed message and close stdin; +7. stream bounded structural and hook-settlement events while the Dispatch hook + provides content-minimized observations; +8. record process exit independently from provider receipt state. + +For later sends, replace `--session-id` with `--resume UUID`. Never use +`--continue` for a managed lane. + +### Process ownership + +The Claude runtime owns every process it spawns. On POSIX, spawn with +`start_new_session=True`, record pid, pgid, process start identity, and generation, +and verify all four before signalling so a reused pid cannot be targeted. Platforms +without equivalent process-group and start-identity checks fail provider startup +as unsupported rather than weakening isolation. On shutdown: + +- stop accepting new sends; +- allow a bounded grace interval for a completing process; +- send SIGINT, then TERM, then KILL only to the verified owned process group, + with bounded waits between stages; +- keep processing-but-incomplete provider state unknown even when an explicit + operator stop marks the Dispatch queue attempt `abandoned_by_operator`; +- remove generated settings after the process group exits. + +Tests use a fake executable with a descendant process and assert the entire group +is gone after escalation. The opt-in Claude scenario repeats the descendant check +against a tool-spawning turn before this provider can be enabled. + +Do not signal Agent View supervisor processes or user-launched Claude sessions. + +### Restart recovery + +At daemon startup, partition lanes by provider. Codex recovery remains in the +App Server supervisor. Claude recovery: + +1. invalidates event nonces from prior daemon generations; +2. reconciles persisted pid/pgid/start identity without reading transcripts; +3. marks missing prior processes stopped; +4. preserves session UUIDs as resumable; +5. keeps `processing_started` without completion as `completion_unknown`; +6. resets queued claims but drains only after the runtime is confirmed ready; +7. never replays an ambiguous message automatically. + +The next explicit send resumes the UUID in a new generation. A future optional +Agent View adapter may inspect metadata, but it is not required for v1 recovery. + +## Hook/settings strategy + +Generate one owner-only settings file per active process. `--settings` merges it +above user/project/local settings without modifying them. The file adds command +hooks for the minimum receipt/attention set: + +- `SessionStart`, `SessionEnd`; +- `UserPromptSubmit`; +- `Stop`, `StopFailure`; +- `PermissionRequest`, `PermissionDenied`, `Notification`; +- `Elicitation`, `ElicitationResult` when verified on the pinned version; +- optional tool events only when needed for attention/runtime state. + +Do not enable `MessageDisplay` or raw transcript capture by default. + +Before any prompt frame is written, start the CLI in stream-input mode and require +a `SessionStart` response from the Dispatch helper containing the current +generation nonce. This detects managed settings that disable the hook channel. +Missing or failed preflight aborts before stdin submission and is therefore safely +retryable. Once a prompt frame may have been written, no automatic retry is safe. + +The hook command is a packaged `dispatch-provider-hook claude` helper invoked +without user-controlled shell interpolation. Environment contains: + +- daemon socket path; +- provider/session ID; +- process generation; +- a random per-generation nonce; +- maximum payload size and schema version. + +The helper: + +1. reads stdin once with a strict byte limit; +2. validates the event and expected session ID; +3. drops content-bearing prompt fields without hashing or retaining them; +4. extracts bounded event/prompt/tool IDs, enums, counts, and booleans; +5. sends the normalized observation over the owner-only daemon socket; +6. emits the generation nonce and `source_delivery_id` only in its structured hook response so the owned + CLI stream can identify the Dispatch hook among composed sibling hooks; +7. fails open for observability errors while reporting hook health separately. + +The nonce prevents stale-generation and accidental misrouting; it is not an +authentication boundary against same-UID sibling hooks, repository code, or tools. +The security boundary is the OS account plus Claude permission/sandbox policy. +Automatic Claude queue draining is unsupported in the first slice. DIS-52 may +enable it only for canonical roots explicitly allowlisted in Dispatch config; it +must not infer trust by reading Claude private state. Roots outside that persisted +allowlist remain manual-send only regardless of `permission_mode`. + +Runtime directories are mode 0700. Generated settings are created with no-follow, +exclusive, atomic mode-0600 writes under generation-specific names. Startup and +rollback sweep only files whose recorded pid/start identity is gone; bounded +retention and `doctor` expose cleanup drift instead of deleting uncertain owners. + +Persist one artifact record with generation, path, pid/pgid/start identity, state, +and timestamps. Normal exit moves `active -> cleanup_pending`; removal is attempted +immediately and after 1, 5, and 30 seconds. Startup retries verified-dead pending +artifacts. A verified-dead artifact older than 24 hours becomes `stale`, blocks a +new generation for that lane, and is reported by `doctor`. A destructive +`runtime.cleanup` op re-verifies the recorded owner is gone before removal. If +identity cannot be proven dead, state is `quarantined`, files remain in place, +launch stays blocked, and remediation is to stop/identify that process—not unsafe +deletion. Rollback follows the same state machine. + +Provider policy hooks that allow/deny actions are a later explicit feature and +must not share fail-open observability semantics accidentally. + +## Receipt and correlation contract + +Persist a cryptographically random `dispatch_message_id` before spawn. Enforce a +unique request ID so retries return the existing receipt rather than spawning. + +Lifecycle: + +```text +created -> transport_started -> frame_not_written|frame_maybe_written +frame_maybe_written -> submission_observed -> processing_started +frame_maybe_written -> acceptance_unknown +processing_started -> stop_observed -> processing_started|completed +processing_started -> completion_unknown|failed|completed +frame_not_written -> failed_before_submission +``` + +Transport attempt state is separate from provider-message receipt state. A process +can be `interrupted` while provider completion remains unknown. Transitions are +monotonic; an upsert may fill a missing timestamp but never erase/regress a later +status. + +Correlation: + +1. one-writer lease identifies the only pending envelope for a session; +2. after a frame write begins, loss is `frame_maybe_written` and never auto-retried; +3. the daemon matches the one pending envelope by session and generation; +4. a Dispatch `UserPromptSubmit` delivery stores Claude `prompt_id` as + `submission_observed`; every sibling prompt hook must reach a terminal outcome, + no hook may block (exit 2 or blocking decision), and first assistant/tool + activity must follow before `processing_started`; fail-open exit 1/cancelled + outcomes degrade hook health but do not erase owned processing evidence; +5. each `Stop`/`StopFailure` joins on session + generation + `prompt_id`; repeated + Stop deliveries are retained as ordered occurrences, not deduplicated; +6. completion requires the last Stop occurrence, settlement of all hooks for that + cycle without a continuation, terminal success result, and clean process exit; +7. late events from dead generations are retained as diagnostic provider events + but cannot mutate the active attempt. + +Every helper invocation generates a `source_delivery_id` before its first socket +write and reuses it across bounded retries; the daemon enforces uniqueness on +`(generation, source_delivery_id)`. Each arrival also gets an append-only +`ingest_id`. Owned stream `hook_id` pairs the start and response for one hook +execution, while the helper echoes its source ID in structured response output. +The reducer assigns an occurrence to +`(generation, prompt_id, event_type)` in owned-stream order. Replay dedupe uses +`source_delivery_id`; it never collapses two legitimate Stop hook invocations. + +Synthetic visible markers were useful in research but are not needed in +production. If a future transport allows multiple concurrent pending messages, +the correlation contract must be redesigned before lifting the lease. + +## Queue, attention, and restart semantics + +### Queue + +The durable queue remains Dispatch-owned. Claim at most one row per Claude lane. +Do not use legacy `lanes.status == idle`, process liveness, zmx status, or +scrollback as readiness. + +Claude is ready only when: + +- no active owned process/attempt exists; +- the last attempt is terminal; +- no unresolved permission/elicitation attention blocks the lane; +- hook health meets policy; +- if `Stop` reported background tasks/crons, those are empty or explicitly + allowed by policy. + +A frame-maybe-written, acceptance-unknown, or completion-unknown attempt blocks +automatic drain. Only an explicit operator resolution can abandon the Dispatch +attempt and release the queue; the provider completion fact remains unknown in the +audit record. + +### Operator recovery for indeterminate attempts + +Status/watch expose `dispatch_message_id`, provider UUID, generation, last owned +stream phase, hook-health/settlement summary, process state, uncertainty reason, +and `safe_to_retry=false`; they never expose prompt or transcript content. The +operator may: + +1. wait for a late owned-stream/hook event to settle the attempt; +2. stop a still-live verified owned process group, which changes transport state + but does not manufacture a provider completion fact; or +3. invoke one authored destructive `attempt.resolve` op with + `resolution="abandon"` and a required reason. + +`attempt.resolve` records actor/time/reason, marks only the Dispatch attempt +`abandoned_by_operator`, releases its queue lease, and leaves +`provider_completion=unknown`. The next explicit send is a new message ID and the +CLI/MCP response warns that the abandoned provider turn may have completed. There +is no force-retry action for the same envelope. + +### Attention + +Map normalized events into the existing inbox/runtime model: + +- `PermissionRequest` -> `needs_permission`; +- `Notification(permission_prompt)` -> corroborating attention snapshot; +- `Elicitation` -> `needs_input`; +- hook unhealthy during an attempt -> `receipt_uncertain`; +- Agent View `blocked/waiting` -> metadata corroboration, never sole durable + request content. + +First slice lists attention and tells the operator to attach/use Agent View. A +later issue may add programmatic provider responses only after supported direct +semantics are proven. + +### Interrupt and interject + +`stop` sends SIGINT only to the exact current owned process group. Process exit +confirms the transport interrupt; absence of `Stop` is expected. Before +`processing_started`, the receipt may be interrupted/failed. After it, provider +completion becomes unknown. An explicit operator stop may additionally record +`abandoned_by_operator` to release Dispatch's queue without pretending the +provider turn did not finish later. + +`interject` remains product-gated. If enabled, it is: + +1. interrupt; +2. await exact process exit; +3. if processing may have started, require explicit `attempt.resolve(abandon)`; +4. only then release the lease and start a new ordinary message; +5. expose that the prior provider turn may still have completed. + +It is not active-turn steer and is not atomic. + +## Operation mapping + +| Dispatch op | Claude v1 behavior | +| --- | --- | +| `new` | supported with explicit UUID and first serialized print process | +| `attach` | unmanaged ordinary-session attach is unsupported in v1: no content-free metadata validation primitive was proven; human Agent View attach is a separate verified UI | +| `send` | supported, one process/attempt, durable request ID | +| `steer` | typed unsupported | +| `queue` | Dispatch-owned, provider-confirmed readiness | +| `interject` | disabled until product semantics accepted | +| `context` | typed unsupported; do not turn into a user message | +| `stop` | supported for current owned process; Agent View stop is not used | +| `attempt.resolve` | destructive explicit abandonment of an indeterminate Dispatch attempt; provider fact stays unknown | +| `tail` / `watch` | normalized provider events and receipt/runtime changes only | +| `history` | disabled by default pending explicit transcript-retention policy | +| `rename` | initial name optional; later mutation product-gated | +| `archive` / `restore` | typed unsupported; Claude `rm` is not archive | +| goals | Dispatch goal orchestration can send turns; Claude goal mutation unsupported | +| permissions | observe/normalize now; automated decisions later | +| structured output | opt-in print request with JSON schema capability | +| rich input | text first; local file/image contract deferred | + +## Config, presets, CLI, and MCP + +Canonical authored input: + +```toml +provider = "claude" +``` + +Execution provider is distinct from Codex `model_provider`. Initial vocabulary is +fixed to `codex|claude`; omitting it remains Codex. + +DIS-50 adds the canonical authored `provider` enum to `new` because the vertical +slice needs an invocable entry point. It updates contract examples and CLI/MCP +canonical projection only; no shorthand is required for the first slice. + +DIS-49 projects `--claude` and `--codex` as CLI-only shorthands for +`new --provider`. Conflicting/multiple selector forms fail before any worktree, +session, registry, or provider mutation. Existing-lane ops do not accept provider +flags; they route by persisted lane identity. + +MCP, config, presets, remote schema, and persisted data expose only the canonical +provider enum. Capability output is identical across CLI and MCP. A provider that +is configured but unavailable raises a typed availability error; it never falls +back to Codex. + +Claude config is intentionally small: + +```toml +[providers.claude] +enabled = false +executable = "claude" +minimum_version = "2.1.210" +default_model = "haiku" +permission_mode = "default" +hook_timeout_seconds = 5 +``` + +Do not add zmx, Remote Control, transcript ingestion, retry, or concurrency knobs +until a shipping feature needs them. + +## Fixtures and tests + +### Unit and contract + +- provider manager resolution and typed unsupported errors; +- capability intersection and surface parity; +- provider-qualified selectors and migration compatibility; +- monotonic receipt transitions and duplicate/out-of-order hooks; +- one-writer lease, request-ID dedupe, queue claim/reset; +- generation fencing, hook-settlement, occurrence ordering, and stale-event rejection; +- hook payload size/schema/redaction tests; +- argv/env construction with shell metacharacters/control bytes; +- no raw prompt/tool/transcript fields persisted. + +### Fake runtime + +Add a fake Claude executable that consumes the same argv/stdin and emits +deterministic hook calls/events for: + +- start/submit/process/Stop-cycle/complete/exit; +- sibling prompt block, hook failure/timeout, and Stop continuation; +- interrupt without Stop; +- StopFailure; +- permission/elicitation attention; +- duplicate and late events; +- process crash before frame write, after possible write, and after processing starts; +- daemon restart and generation change. + +Use shared provider contract tests against Codex and Claude fakes where semantics +overlap. Do not bloat the existing App Server fake with Claude methods. + +### Sanitized provider fixtures + +Promote content-free hook shapes from `spikes/claude/fixtures/` into exercised +test fixtures. Tests must load every checked-in fixture. Pin expected event +schemas to the supported Claude version and keep unknown event types visible. + +### Opt-in live scenario + +A small temp-repo scenario, outside `just check`, uses Haiku and proves: + +- explicit UUID and first message; +- structurally corroborated processing/completion; +- fresh-process second message; +- interrupt and resume; +- duplicate request ID does not create a second provider prompt; +- process cleanup and unchanged live settings hashes. + +Never use existing sessions or global/project settings. + +## Rollout + +1. Ship migration/capability/runtime skeleton behind `providers.claude.enabled = + false`. +2. Dogfood with opt-in temp projects and inspect only normalized events. +3. Enable explicit `new --provider claude`; keep Codex default. +4. Add provider status/doctor output: executable/version, hook health, active + process count, last receipt, and cleanup drift—never auth or transcript data. +5. Expand queue/attention only after receipt/restart telemetry is stable. +6. Consider Agent View response, Remote Control, rich input, or zmx only through + separate evidence/decision issues. + +Rollback disables new Claude launches, stops only Dispatch-owned active Claude +processes, preserves provider identities/receipts, and leaves sessions resumable +by UUID. Schema rollback is not destructive; compatibility readers tolerate the +new provider columns. + +## Ordered implementation slices + +### 1. DIS-50 — Vertical Claude walking skeleton + +One PR, not an abstraction-only precursor: + +- lane identity migration and provider-qualified routing; +- fixed provider manager with existing Codex adapter; +- Claude runtime launch/resume process owner; +- generated per-process settings and content-minimizing hook with preflight; +- persisted random request ID and monotonic processing/completion receipt; +- `new`, `send`, `stop`, watch/status, restart recovery, second message; +- capability projection and typed unsupported operations; +- fake runtime plus opt-in live temp-repo scenario. + +Acceptance: a daemon restart between completed turns preserves the UUID; a later +send completes; duplicate client request ID creates no second Claude prompt; +SIGINT proves transport interruption; after processing starts, provider completion +remains unknown until later evidence or explicit audited abandonment. No raw +content is retained. + +### 2. DIS-51 — Receipt and reducer hardening + +- attempt table/generation fencing; +- hook health, StopFailure, late/out-of-order events; +- provider-scoped dedupe and receipt monotonicity migration; +- bounded provider event fixtures and privacy tests. + +### 3. DIS-52 — Queue and attention + +- provider-confirmed readiness; +- single in-flight claim and restart reset; +- permission/notification/elicitation inbox mapping; +- uncertain-attempt operator recovery. + +### 4. DIS-53 — Agent View metadata discovery + +- provider-qualified unmanaged discovery metadata only; +- Agent View metadata adapter if still supported; +- authority policy and effective-worktree metadata; +- stale identity/cwd recovery. + +### 5. DIS-49 — Provider-selection shorthand surfaces + +- derived `--claude` / `--codex` CLI shorthands over DIS-50's canonical enum; +- config/presets/schema/help/completion/skill docs; +- parity and conflict tests. + +### 6. Optional capabilities + +Separate decisions/issues for structured output, history retention, programmatic +attention response, rename, rich input, Remote Control, and any future zmx build. +Unsupported operations remain explicit until their issue closes with evidence. + +## Risks and non-goals + +Highest risks are wrong-provider routing, stale or same-UID-spoofed hooks, duplicate turns, +receipt regression, raw-content retention, and accidental signalling of a user +process. The first slice must test each boundary. + +Non-goals: generalized provider plugins, production zmx, Remote Control server, +mesh/SSH transport, global Claude settings installation, transcript indexing, +provider-driven automatic fallback, release/publish, or changes to Claude auth. diff --git a/docs/research/claude-control-plane-verification.md b/docs/research/claude-control-plane-verification.md new file mode 100644 index 0000000..8a9b2d0 --- /dev/null +++ b/docs/research/claude-control-plane-verification.md @@ -0,0 +1,299 @@ +# Claude control-plane verification + +Date: 2026-07-15 +Installed baseline: Claude Code 2.1.210, zmx 0.6.0, Dispatch 0.10.0 +Research target: supported Claude Code CLI and Agent View surfaces, not the +Claude Agent SDK + +## Verdict + +Dispatch can support durable, multi-turn Claude sessions without a persistent +PTY. The smallest trustworthy transport is one owned, serialized +`claude --resume --print` subprocess per message. Dispatch chooses the +UUID, supplies per-invocation settings, receives structured stream output, and +combines its content-minimized hook with the owned CLI stream. No individual +hook, process exit, or output text is acceptance or completion authority. + +The installed CLI proved this sequence: + +1. choose a UUID and start a disposable session; +2. observe `UserPromptSubmit` with the same session UUID and a provider-generated + `prompt_id`, then observe all prompt hooks settle and assistant/tool activity; +3. observe one or more `Stop` cycles with the same `prompt_id`, settling the final + hook set before terminal result success and clean process exit; +4. start a fresh process with `--resume ` and complete another message; +5. interrupt an active owned process with SIGINT (exit 130, no `Stop`), then + resume the same UUID successfully; +6. correlate concurrent completions by `prompt_id`, while also proving that + concurrent order and duplicate suppression are not provider guarantees. + +Agent View separately proved durable supervisor ownership, attention metadata, +human attach/reply, detach, stop, respawn, and cleanup. Its shell commands do +not expose a non-interactive reply primitive, so it is a supervision surface, +not Dispatch's first message transport. + +zmx 0.6.0 can persist a PTY, but its raw send has no acknowledgement and can +return zero after a transport failure. Tagged source also shows that it always +logs PTY input bytes in recoverable hexadecimal. It is excluded from the first +implementation slice. + +## Evidence rules + +- Official documentation is a rolling surface. Retrieval date is 2026-07-15; + local claims are tied to Claude Code 2.1.210 or zmx 0.6.0. +- Existing Claude sessions were inspected only as a count of metadata rows. + No existing session was opened, messaged, interrupted, renamed, or read. +- Every live message was synthetic, used Haiku, and ran in a temporary Git + repository with an explicit settings file. +- Hook capture retained only event/session/prompt IDs, bounded lifecycle + fields, input key names, and synthetic markers. Prompts, model output, + transcript paths, cwd, tool input/output, and raw transcripts were dropped. +- Exit status, stream replay, zmx status, and terminal scrollback are never + treated alone as Claude acceptance or completion receipts. + +## Source and version ledger + +| Source | Retrieved/version | What it establishes | Evidence class | +| --- | --- | --- | --- | +| [CLI reference](https://code.claude.com/docs/en/cli-usage) | 2026-07-15 / rolling | print/stream JSON, session ID, resume/fork/name, settings, permissions, structured output, worktrees, remote control | documented | +| [Sessions](https://code.claude.com/docs/en/sessions) | 2026-07-15 / rolling | project-scoped durable UUIDs, names, resume, fork, concurrent transcript interleaving, local retention | documented | +| [Agent View](https://code.claude.com/docs/en/agent-view) | 2026-07-15 / research preview | background supervisor, short/full IDs, states, human reply/attach, stop/respawn/rm, lazy worktrees | documented + observed | +| [Hooks](https://code.claude.com/docs/en/hooks) | 2026-07-15 / rolling | schemas, ordering points, blocking/fail-open behavior, timeouts, `prompt_id`, attention and completion events | documented + observed | +| [Settings](https://code.claude.com/docs/en/settings) | 2026-07-15 / rolling | managed > CLI > local > project > user precedence; object/array merge behavior | documented + observed composition | +| [Remote Control](https://code.claude.com/docs/en/remote-control) | 2026-07-15 / research preview | outbound Anthropic relay, local execution, multi-device input, reconnection, product constraints | documented | +| [zmx docs](https://zmx.sh/) and [tagged source](https://github.com/neurosnap/zmx/tree/v0.6.0) | 0.6.0 | PTY ownership, raw send, no ACK, buffer/drop behavior, modes, input logging | primary source + fake target | +| Installed help and probes in `spikes/claude/` | Claude Code 2.1.210 / zmx 0.6.0 | actual flags, event shapes, identity, lifecycle, interrupt, concurrency, failure, cleanup | observed | + +The packet's old `/docs/en/cli-reference` URL redirects; the current canonical +CLI page is `/docs/en/cli-usage`. + +## Supported surfaces + +### Print and stream JSON + +`--input-format stream-json` and `--output-format stream-json` are print-mode +surfaces. `--replay-user-messages` only re-emits stdin messages; it is a +transport echo, not acceptance. `--include-hook-events` exposes hook starts and +responses, including exit/outcome, while the Dispatch hook independently sends +content-minimized events to the daemon. + +The session UUID is available in the init/result stream and in every captured +hook. `--session-id` chooses it for a new session. `--resume ` adds another +turn from a fresh process. `--fork-session` creates a different UUID with copied +history. + +### Agent View + +`claude --bg` returns a short management ID. `claude agents --json` exposes the +full `sessionId` when available plus state, status, waiting reason, pid, kind, +name, and cwd. The probe observed: + +```json +{ + "id": "518b912b", + "sessionId": "518b912b-...", + "state": "blocked", + "status": "waiting", + "waitingFor": "permission prompt", + "kind": "background" +} +``` + +The short ID is a supervisor selector; the full UUID is the conversation +identity. A background session can move from its launch cwd into a Claude-managed +worktree before editing, so effective cwd is observed state, not immutable launch +metadata. + +Human reply is supported through Agent View peek or an attached TUI. Shell +management is limited to list, attach, logs, stop/kill, respawn, and rm. `logs` +is diagnostic output. `rm` removes the Agent View entry/worktree but deliberately +leaves the local conversation resumable; it is not archive or transcript delete. + +### Hooks and receipt joins + +The observed minimum fields were: + +| Event | Stable correlation | Meaning | +| --- | --- | --- | +| `SessionStart` | `session_id`, source | process/session lifecycle; source included `resume` | +| `UserPromptSubmit` | `session_id`, `prompt_id` | prompt reached the provider hook boundary before processing | +| `PermissionRequest` | session/prompt IDs, tool name | action needs a decision | +| `Notification` | session/prompt IDs, notification type | coarse attention signal | +| `PreToolUse` / `PostToolUse` | session/prompt/tool-use IDs | tool lifecycle; content omitted | +| `Stop` | `session_id`, `prompt_id` | one stop cycle began; a sibling hook may continue the turn | +| `StopFailure` | session/prompt IDs | API failure path; not observed live | +| `SessionEnd` | session ID, reason | process/session lifecycle, not message completion | + +`UserPromptSubmit` is submission evidence, not acceptance. A sibling prompt hook +can exit 2 after the Dispatch observer succeeds; the CLI can still emit a success +result without assistant activity or `Stop`. Processing is confirmed only after +all prompt hooks settle successfully and the owned stream begins assistant/tool +activity for the correlated prompt. + +`Stop` joins to the same `prompt_id`, but is repeatable: a sibling Stop hook can +continue the turn, producing later assistant activity and another Stop cycle. +Completion requires the final Stop hook set to settle without continuation, +followed by terminal result success and clean owned-process exit. It does not fire +on user interrupt and does not prove background tasks or session crons quiescent. + +Per-invocation settings composed with existing settings: stream output showed +multiple existing `SessionStart`/`Stop` hooks plus the Dispatch hook. Dispatch +must never assume exclusive ownership or mutate user/project settings. + +### Remote Control + +Remote Control is an explicit Anthropic-hosted relay over outbound HTTPS/TLS; +execution stays local and browser/mobile/terminal inputs synchronize. It requires +subscription authentication, workspace trust, policy enablement, a long-running +local process, and approximately ten-minute outage bounds. It exposes no +documented local programmable send RPC. This is a future operator/product choice, +not the default local Dispatch transport. + +### zmx + +The isolated fake target confirmed the primary-source contract: + +- simultaneous raw sends arrived `b` then `a` although `a` was launched first; +- a raw Ctrl-C send returned success but target completion still occurred; +- after session kill, raw send printed an unresponsive error and exited zero; +- successful send therefore proves neither target acceptance nor completion. + +zmx also maintains a 256 KiB PTY input queue whose overflow can drop input +without sender notification. Version 0.6.0 logs PTY input bytes at debug level +and does not expose a disable/redaction control. Private modes reduce who can +read the logs but do not satisfy a no-raw-prompt-retention boundary. + +## Lifecycle state machine + +```mermaid +stateDiagram-v2 + [*] --> Known: choose UUID and persist lane + Known --> Starting: spawn claude --session-id/--resume + Starting --> FrameNotWritten: preflight failed before stdin + Starting --> FrameMaybeWritten: stdin write began + FrameMaybeWritten --> SubmittedObserved: Dispatch UserPromptSubmit observed + SubmittedObserved --> ProcessingStarted: hooks settle without block and model/tool activity begins + FrameMaybeWritten --> AcceptanceUnknown: transport or hook settlement lost + ProcessingStarted --> Running: correlated model/tool activity + Running --> NeedsAttention: permission/notification/elicitation + NeedsAttention --> Running: supported human/provider response + Running --> StopObserved: Stop with matching prompt_id + StopObserved --> Running: settled sibling hook continues turn + StopObserved --> Completed: final hooks settle, result succeeds, process exits cleanly + Running --> CompletionUnknown: owned process lost or interrupted after processing began + Running --> Failed: StopFailure or process failure + AcceptanceUnknown --> Failed: explicit operator reconciliation + Completed --> Known: process exited; session UUID remains resumable + Failed --> Known: explicit recovery decision +``` + +Only one `Starting` through terminal attempt may exist per session. A process +generation and attempt ID prevent late hooks from a dead process from changing a +new attempt. + +## Capability matrix + +Every row is resolved. `Verified` may be a direct Claude primitive or a safe +Dispatch composition; `product-decision` means the primitive exists but the +cross-provider product semantics must be chosen; `unsupported` means the first +adapter must return a typed capability error. + +| Capability | Status | Primitive / Dispatch composition | Acceptance and completion | Failure / recovery | Confidence | +| --- | --- | --- | --- | --- | --- | +| Durable identity | verified | caller-chosen UUID; Agent View also has separate short ID | UUID matches stream and hooks | resolve UUID in project/worktree scope; never route by mutable name | high, observed 2.1.210 | +| New | verified | persist UUID, preflight hook channel, spawn `--session-id UUID --print`; optional `--bg` is human-supervised mode | prompt hook settlement + owned activity; final settled Stop cycle + result/exit | retry only when proven no frame write began; otherwise operator reconciliation | high | +| Owned resume-for-send | verified | Dispatch starts `--resume UUID`; never managed `--continue` | `SessionStart(source=resume)` then ordinary aggregate receipts | stale/wrong-cwd lookup is typed not-found | high | +| Human Agent View attach | verified | human `claude attach SHORT_ID` to a known disposable Agent View entry | UI and ordinary hooks; not a Dispatch send transport | human supervision only; no shell reply RPC | high | +| Dispatch attach of an unmanaged ordinary session | unsupported | no content-free metadata validation primitive was proven | none | do not register writable authority from UUID alone | high | +| Send | verified | serialized fresh `--resume UUID --print` process per message | processing after aggregate prompt settlement + activity; completion after final Stop settlement/result/exit | any possible frame write plus loss is indeterminate; never auto-retry | high | +| Steer active turn | unsupported | no documented print-process steer RPC; TUI input semantics are not equivalent | none | expose unsupported, do not queue under a steer label | high | +| Durable queue/readiness | product-decision | Dispatch queue + one-writer lease; drain only after terminal attempt, empty background work, and healthy hooks | next aggregate receipt sequence | frame/acceptance/completion uncertainty blocks drain until explicit operator resolution | high evidence, policy open | +| Interject | product-decision | SIGINT verified owned process group, await exit, then start a new turn | exit proves transport interruption; new prompt needs normal receipts | not atomic; provider completion may remain unknown | high evidence, semantics open | +| Context injection | unsupported | no safe equivalent to Codex model-visible non-user context injection; system-prompt flags are launch configuration | none | expose unsupported; do not relabel a user message | high | +| Stop/interrupt | verified | SIGINT current verified owned print-process group; Agent View `stop` is separate whole-session UI | exit proves transport interruption; absence of `Stop` is expected | after processing, provider completion stays unknown; explicit operator abandonment can release queue | high | +| Tail/history | product-decision | live structured output/hooks are safe watch; Agent View logs and local JSONL contain content | event cursor, not scrollback | default history ingest remains off; explicit transcript policy required | high | +| Watch/events | verified | per-invocation hooks + owned structured output | hook IDs, occurrence order, prompt/session IDs | delivery-ID replay dedupe, generation fence, health timeout; same-UID trust is advisory | high | +| Rename | product-decision | `--name` at launch, `/rename` or Agent View UI later | metadata observation | no documented non-interactive rename command; UUID remains authority | medium | +| Archive/restore | unsupported | `rm` removes management entry/worktree but retains resumable transcript | none | do not call it archive/delete; resume is not restore from archive | high | +| Goal loop | product-decision | Dispatch-owned goals/triggers may send ordinary turns; Claude `/loop` is provider UI semantics | normal per-turn receipts | do not claim Codex goal parity | medium | +| Permissions/approval | verified | permission modes and `PermissionRequest` hook decisions | request/decision audit + later tool/Stop event | timeout/failure is explicit attention/unknown; never auto-allow by transport | high | +| User input/elicitation | product-decision | attention hooks + Agent View/attach human response | shared `prompt_id`; later `Stop` | no shell reply RPC; first slice surfaces attention but does not synthesize answers | high | +| Structured output | verified | print mode `--json-schema` and JSON/stream result | validated structured result + normal Stop | schema failure is typed provider failure; not an interactive-lane default | medium-high, documented/help | +| Rich input/files/images | product-decision | `--file` addresses provider file resources; Agent View supports human image paste | normal prompt receipt | local file/image stream contract not established; first slice text only | medium | +| Process restart/recovery | verified | fresh `--resume`; Agent View stop/respawn retained full UUID | `SessionStart(resume)` and later receipts | partition recovery by provider; do not call Codex resume for Claude | high | +| Duplicate/concurrent send | verified | provider processes both and generates distinct prompt IDs | each turn has independent aggregate receipt cycles | enforce one writer; duplicate request ID returns stored receipt; ambiguous attempt not retried | high | +| Remote/mesh compatibility | product-decision | Remote Control is Anthropic relay; Dispatch mesh remains owning-daemon execution | provider hooks at owner | no private endpoint or shared remote process; future explicit config/policy | high | + +## Failure and recovery matrix + +| Failure | Observed/documented result | Owner | Required recovery | +| --- | --- | --- | --- | +| Preflight fails before stdin | no frame write | Claude runtime | fail before submission; bounded retry with same request ID is safe | +| Transport loss after write begins | frame may have reached Claude; no conclusive receipt | Claude runtime + operator | mark acceptance unknown; block queue; operator can wait, inspect content-free runtime facts, or explicitly abandon ambiguity before a new send | +| Process dies after processing starts | processing evidence but no final settled Stop/result/exit | Claude runtime + operator | mark completion unknown; never resend automatically; explicit abandonment releases queue but preserves unknown provider fact | +| SIGINT during active turn | exit 130, no new `Stop`; same UUID resumable | Claude runtime | record transport interruption; after processing starts retain completion unknown until operator abandons or later evidence resolves it | +| Prompt hook block after observer | Dispatch hook succeeds, sibling exits 2, no assistant/Stop, result may say success | hook reducer | submission only; do not mark processing | +| Stop hook continuation | repeated Stop cycles with same prompt ID | hook reducer | retain each occurrence; only final settled cycle plus result/exit completes | +| Hook exit 1 | prompt may proceed | hook ingest | mark hook unhealthy/acceptance unknown unless complete aggregate evidence exists | +| Hook timeout | hook response cancelled/exit 1; prompt proceeds | hook ingest | same as exit 1; never interpret as rejection | +| Duplicate request | two provider turns if sent twice | Dispatch | dedupe before spawn by durable Dispatch message ID | +| Concurrent writers | both produced turns; ordering is not launch order | Dispatch | single-writer transaction/lease per session | +| Permission/user input | hooks + Agent View waiting metadata | Dispatch attention reducer + human | durable inbox item; first slice requires attach/Agent View response | +| Daemon restart | Claude transcript persists; owned process may be gone | Dispatch supervisor | fence old generation, inspect pid, resume only on next explicit send | +| zmx loss | send may error text and still exit zero | zmx adapter | excluded; if ever enabled, receipts must come only from Claude hooks | +| Stale session/cwd | resume lookup is project/worktree scoped | selector/runtime | store canonical launch/effective cwd and return typed stale/not-found | + +## Security and privacy findings + +- Never retain raw hook input by default. Validate size/type, extract bounded + fields, drop prompt/transcript/tool/message content, then persist. +- A hook command is executable configuration. Generate an owner-only settings + file in Dispatch runtime state, use argv-safe process creation, and never + interpolate user text into a shell command. +- Give each process generation a random nonce. The Dispatch hook returns it in + structured hook output so the owned stream can distinguish that helper and + reject stale generations. The nonce is a misrouting fence, not authentication + against same-UID sibling hooks, repository code, or tools. +- Correlate `UserPromptSubmit` to the only persisted in-flight envelope by + provider UUID, generation, and one-writer lease. Persist the provider + `prompt_id`; do not retain a prompt digest or expose a marker to the model. +- Treat the OS user and Claude permission/sandbox policy as the security boundary. + Same-UID hook observations are advisory and require owned-stream corroboration. +- Preflight the Dispatch `SessionStart` hook response before writing a prompt. + Managed settings can disable hooks; missing nonce aborts before submission. +- User/project/managed hooks continue to run. Dispatch settings add one hook and + never weaken permissions or replace settings files. +- Treat terminal output, Agent View logs, local transcripts, and zmx history/logs + as content-bearing. They are outside default receipt ingestion. +- Do not enable Remote Control automatically. It changes external routing, + authentication, availability, and policy boundaries. +- Session names, short IDs, titles, and cwd are selectors/metadata, never + authorization. Route on `(provider, full session UUID)` plus local lane key. + +## Contradictions and confidence limits + +- Current official docs are newer than the installed binary in places. Rows + based only on rolling docs/help are medium confidence until an implementation + fixture pins the installed event. +- Agent View offers human reply, refining the older statement that it has no + send path. It still lacks a documented scriptable reply command. +- `Stop` is a repeatable cycle boundary, not by itself completion, delivery + acceptance, user interrupt, API failure, or whole-session quiescence. +- zmx `run` completion markers concern shell commands, not interactive Claude + turns. Raw `send` remains fire-and-forget. +- Concurrent `--resume` succeeds, but official docs and observed order both make + it unsafe for an ordered Dispatch queue. + +## Reproduction and cleanup + +The tracked scripts in `spikes/claude/` are content-minimizing fixtures. Exact +commands and sanitized sequences are documented there. The run used one temp +repository, two disposable Agent View entries, one isolated zmx fake session, +and print-mode UUIDs. Both Agent View entries were stopped and removed, the zmx +namespace returned zero sessions, and no research process remained. + +Claude intentionally retains local resumable conversation files for its normal +retention period; the supported `rm` command does not delete those transcripts. +The research did not locate, read, or manually delete transcript files. That +provider retention is why `archive/delete` is marked unsupported rather than +pretending cleanup has stronger semantics. diff --git a/spikes/claude/README.md b/spikes/claude/README.md new file mode 100644 index 0000000..7292432 --- /dev/null +++ b/spikes/claude/README.md @@ -0,0 +1,370 @@ +# Claude control-plane probes + +These probes support +[`claude-control-plane-verification.md`](../../docs/research/claude-control-plane-verification.md). +They are research fixtures, not a production Claude adapter. + +`hook_capture.sh` is a content-minimizing command hook. It records only event +names, session/prompt IDs, bounded lifecycle fields, input key names, and +synthetic `DISPATCH-PROBE:` markers. It drops prompts, transcript paths, +working directories, tool input/output, and model output. + +`hook_fail.sh` is a silent exit-1 fixture. `hook_timeout.sh` is a silent +two-second delay fixture used with a one-second timeout. `hook_block_prompt.sh` +is a sibling blocking hook; `hook_continue_once.sh` continues exactly one Stop +cycle; and `hook_preflight.sh` returns a caller-supplied generation nonce before +message submission. `fake_repl.sh` emits +synthetic target-owned acceptance/completion markers for safe zmx experiments; +those markers characterize zmx only and never substitute for Claude hooks. + +`fixtures/receipt-sequence.jsonl` and `fixtures/aggregate-receipts.jsonl` are +content-free observed schema/structural sequences. +UUIDs and markers are synthetic; `keys` preserves the installed input shape +without retaining content-bearing values. + +`sanitize_stream.jq` drops content while Claude is running. `assert_probe.py` +checks receipt, blocked-prompt, continued-Stop, fail-open, timeout, preflight, +interrupt, duplicate, and replay cases. The static replay/Stop distinction is: + +```bash +uv run python spikes/claude/assert_probe.py aggregate-fixture \ + spikes/claude/fixtures/aggregate-receipts.jsonl +uv run python spikes/claude/assert_probe.py negative-fixtures \ + spikes/claude/fixtures/negative +jq -cf spikes/claude/sanitize_stream.jq \ + spikes/claude/fixtures/whitespace-block-raw.jsonl | + jq -e '.blocking_decision == true and (has("stdout") | not)' +``` + +## Safety envelope + +- Use Claude Code 2.1.210 and zmx 0.6.0 for these exact claims. +- Use Haiku, minimal prompts, an explicit UUID, and a temporary Git repository. +- Never target an existing session or existing Agent View short ID. +- Never inspect transcripts, auth, or zmx history for real Claude prompts. +- Record settings hashes/metadata without printing settings contents. +- Use a unique prefix and remove every Agent View/zmx entry created. +- A successful process or zmx exit is not a receipt. + +From the repository root, create the isolated workspace and generate an exact +mode-0600 settings file instead of reconstructing hook syntax: + +```bash +spike_root="$PWD/spikes/claude" +repo="$(mktemp -d -t dispatch-claude-probe.XXXXXX)" +git -C "$repo" init -q +export DISPATCH_CLAUDE_PROBE_LOG="$repo/hooks.jsonl" +settings="$repo/dispatch-claude-settings.json" +"$spike_root/make_settings.sh" "$settings" receipt +trap 'rm -rf "$repo"' EXIT +set -o pipefail +``` + +The examples use Bash's `PIPESTATUS`; run these blocks in Bash. + +Available modes are `receipt`, `block-prompt`, `continue-stop`, `fail`, `timeout`, +and `preflight`. The capture modes require `DISPATCH_CLAUDE_PROBE_LOG` inside the +temporary repo. `continue-stop` additionally requires +`DISPATCH_CLAUDE_STOP_STATE` there. Generate preflight settings with a synthetic +nonce in the environment; never store or print other hook stdout. + +## Minimal receipt/resume probe + +Create a temp repository and use `make_settings.sh ... receipt` to register +`hook_capture.sh` for `SessionStart`, `UserPromptSubmit`, `Stop`, `StopFailure`, +and `SessionEnd`. +Point `DISPATCH_CLAUDE_PROBE_LOG` inside the temp directory, then run: + +```bash +session_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" + +set +e +jq -cn --arg prompt 'DISPATCH-PROBE:first Reply only OK.' \ + '{type:"user",message:{role:"user",content:$prompt}}' | + claude --print --verbose \ + --input-format stream-json \ + --output-format stream-json \ + --include-hook-events \ + --replay-user-messages \ + --session-id "$session_id" \ + --settings "$settings" \ + --model haiku \ + --max-turns 1 | + jq -cf "$spike_root/sanitize_stream.jq" \ + > "$repo/first.structure.jsonl" +first_status=${PIPESTATUS[1]} +jq -cn --argjson code "$first_status" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' \ + >> "$repo/first.structure.jsonl" + +claude --resume "$session_id" --print \ + 'DISPATCH-PROBE:second Reply only OK.' \ + --verbose \ + --output-format stream-json \ + --include-hook-events \ + --settings "$settings" \ + --model haiku \ + --max-turns 1 | + jq -cf "$spike_root/sanitize_stream.jq" \ + > "$repo/second.structure.jsonl" +second_status=${PIPESTATUS[0]} +jq -cn --argjson code "$second_status" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' \ + >> "$repo/second.structure.jsonl" +set -e + +uv run python "$spike_root/assert_probe.py" receipt \ + "$repo/first.structure.jsonl" +uv run python "$spike_root/assert_probe.py" receipt \ + "$repo/second.structure.jsonl" +``` + +Reduce stdout immediately to type/subtype/session ID, hook name/outcome, and +error state. Do not save assistant/user content. Verify the sanitized hook log +has the selected UUID, both markers, and matching prompt IDs. This proves +correlation only. Processing additionally requires every `UserPromptSubmit` hook +response to reach a terminal non-blocking outcome and owned-stream assistant/tool +activity. +Completion requires the final Stop hook set to settle without continuation, +terminal result success, and clean process exit. + +## Hook aggregation and preflight probes + +Configure `hook_capture.sh` and `hook_block_prompt.sh` as sibling +`UserPromptSubmit` hooks. Claude Code 2.1.210 ran the observer, then the blocker +exited 2; there was no assistant activity or Stop even though the terminal result +subtype said success. This is the negative acceptance fixture. + +Configure `hook_capture.sh` and `hook_continue_once.sh` as sibling `Stop` hooks, +with `DISPATCH_CLAUDE_STOP_STATE` pointing inside the temporary directory. The +same prompt ID produced two Stop occurrences separated by assistant activity. +This is the negative single-Stop completion fixture. + +Run the aggregate and fail-open cases with the same sanitized pipeline: + +```bash +for mode in block-prompt continue-stop fail timeout; do + : > "$DISPATCH_CLAUDE_PROBE_LOG" + settings="$repo/$mode.settings.json" + structure="$repo/$mode.structure.jsonl" + if [ "$mode" = continue-stop ]; then + export DISPATCH_CLAUDE_STOP_STATE="$repo/continue-once" + fi + "$spike_root/make_settings.sh" "$settings" "$mode" + case $mode in + block-prompt) prompt='DISPATCH-PROBE:block Reply only OK.' ;; + continue-stop) prompt='DISPATCH-PROBE:continue Reply, then continue once.' ;; + fail) prompt='DISPATCH-PROBE:fail Reply only OK.' ;; + timeout) prompt='DISPATCH-PROBE:timeout Reply only OK.' ;; + esac + set +e + jq -cn --arg prompt "$prompt" \ + '{type:"user",message:{role:"user",content:$prompt}}' | + claude --print --verbose \ + --input-format stream-json --output-format stream-json \ + --include-hook-events \ + --session-id "$(uuidgen | tr '[:upper:]' '[:lower:]')" \ + --settings "$settings" --model haiku --max-turns 2 | + jq -cf "$spike_root/sanitize_stream.jq" > "$structure" + claude_status=${PIPESTATUS[1]} + jq -cn --argjson code "$claude_status" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' >> "$structure" + set -e + uv run python "$spike_root/assert_probe.py" "$mode" "$structure" +done +``` + +For a no-message preflight, register `hook_preflight.sh` on `SessionStart`, then: + +```bash +session_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" +preflight_settings="$repo/preflight.settings.json" +export DISPATCH_CLAUDE_PREFLIGHT_NONCE="preflight-$session_id" +"$spike_root/make_settings.sh" "$preflight_settings" preflight +set +e +claude --print --verbose \ + --input-format stream-json \ + --output-format stream-json \ + --include-hook-events \ + --session-id "$session_id" \ + --settings "$preflight_settings" \ + --model haiku "$repo/preflight.structure.jsonl" +preflight_status=${PIPESTATUS[0]} +jq -cn --argjson code "$preflight_status" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' \ + >> "$repo/preflight.structure.jsonl" +set -e +uv run python "$spike_root/assert_probe.py" preflight \ + "$repo/preflight.structure.jsonl" +``` + +Require one successful response carrying the current nonce before writing any +prompt frame. Do not persist other hook stdout: sibling SessionStart hooks may +return content. A missing nonce is safe failure-before-submission. After any +possible stdin write, loss is indeterminate and must never auto-retry. + +## Interrupt, duplicate, and hook-failure probes + +For interrupt, resume with a synthetic prompt that invokes `sleep 30`, wait for +processing evidence, send SIGINT to the exact owned process, and require exit +130 with no matching `Stop`. A later fresh `--resume` must complete, but the +provider completion of the interrupted attempt remains unknown. + +For concurrency/duplicate behavior, start two resume processes against the same +disposable UUID with distinct markers, then repeat one marker. Claude 2.1.210 +processed both and assigned distinct prompt IDs. Production must serialize and +dedupe before spawn. + +After creating one disposable `$session_id`, prove the duplicate behavior without +retaining output content: + +```bash +: > "$DISPATCH_CLAUDE_PROBE_LOG" +settings="$repo/duplicate.settings.json" +"$spike_root/make_settings.sh" "$settings" receipt +duplicate_pids="" +for copy in 1 2; do + ( + structure="$repo/duplicate-$copy.structure.jsonl" + set +e + claude --resume "$session_id" --print \ + 'DISPATCH-PROBE:duplicate Reply only OK.' \ + --verbose --output-format stream-json --include-hook-events \ + --settings "$settings" --model haiku --max-turns 1 | + jq -cf "$spike_root/sanitize_stream.jq" > "$structure" + status=${PIPESTATUS[0]} + jq -cn --argjson code "$status" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' >> "$structure" + set -e + exit "$status" + ) & + duplicate_pids="$duplicate_pids $!" +done +for pid in $duplicate_pids; do wait "$pid"; done +uv run python "$spike_root/assert_probe.py" duplicate \ + "$DISPATCH_CLAUDE_PROBE_LOG" \ + "$repo/duplicate-1.structure.jsonl" "$repo/duplicate-2.structure.jsonl" +``` + +Add `hook_fail.sh` beside the capture hook on `UserPromptSubmit`. The turn should +complete while stream output reports exit 1 for the failing hook. Repeat with +`hook_timeout.sh` configured with a one-second timeout; 2.1.210 reported +`outcome=cancelled`, exit 1, and still completed. Neither failure is rejection. + +For interrupt, use process substitution so raw output is reduced in flight while +`$!` remains the owned Claude pid: + +```bash +: > "$DISPATCH_CLAUDE_PROBE_LOG" +settings="$repo/interrupt.settings.json" +"$spike_root/make_settings.sh" "$settings" receipt +claude --resume "$session_id" --print \ + 'DISPATCH-PROBE:interrupt Use Bash to run sleep 30.' \ + --verbose --output-format stream-json --include-hook-events \ + --settings "$settings" --model haiku --permission-mode bypassPermissions \ + > >(jq -cf "$spike_root/sanitize_stream.jq" \ + > "$repo/interrupt.structure.jsonl") & +claude_pid=$! +for _ in $(seq 1 100); do + jq -e 'select(.hook_event_name == "PreToolUse")' \ + "$DISPATCH_CLAUDE_PROBE_LOG" >/dev/null && break + sleep 0.1 +done +kill -INT "$claude_pid" +set +e +wait "$claude_pid" +code=$? +set -e +test "$code" -eq 130 +jq -cn --argjson code "$code" \ + '{sequence:999999,type:"process_exit",exit_code:$code}' \ + >> "$repo/interrupt.structure.jsonl" +uv run python "$spike_root/assert_probe.py" interrupt \ + "$repo/interrupt.structure.jsonl" +``` + +## Agent View probe + +Start a uniquely named background session: + +```bash +claude --bg \ + --name "$unique_name" \ + --settings "$settings" \ + --model haiku \ + --permission-mode default \ + 'DISPATCH-PROBE:attention Use AskUserQuestion to ask A or B, then wait.' +``` + +Reduce `claude agents --json --cwd "$repo"` to id/sessionId/name/state/status/ +waitingFor/kind and cwd equality. The observed session reached blocked/waiting, +with `PermissionRequest` and `Notification(permission_prompt)`. Use +`claude attach SHORT_ID` only for that disposable session, answer, detach with +Ctrl-Z, and send a second synthetic prompt if testing the human path. + +Cleanup is mandatory: + +```bash +claude stop "$short_id" +claude rm "$short_id" +claude agents --json --all --cwd "$repo" +``` + +The filtered result must contain no created ID. `rm` removes the Agent View +entry/worktree but intentionally leaves Claude's resumable transcript. + +## Isolated zmx fake-target probe + +Never send a real Claude prompt through zmx 0.6.0 in this probe: that version +logs PTY input bytes. Use a unique `ZMX_DIR`, `ZMX_DIR_MODE=0700`, +`ZMX_LOG_MODE=0600`, and `fake_repl.sh` only. + +The verified negative cases were simultaneous raw sends arriving in a different +order, Ctrl-C send success without target interruption, and post-kill send exit +zero despite an unresponsive-session error. Finish with `zmx kill` and require +the isolated `zmx list --short` count to be zero. + +The exact fake-only sequence is: + +```bash +export ZMX_DIR="$repo/zmx" +export ZMX_DIR_MODE=0700 +export ZMX_LOG_MODE=0600 +zmx_name="dispatch-probe-$$" +zmx attach "$zmx_name" "$spike_root/fake_repl.sh" \ + /dev/null 2>&1 & +zmx_client=$! +for _ in $(seq 1 50); do + zmx list --short | rg -qx "$zmx_name" && break + sleep 0.1 +done +(printf 'a\n' | zmx send "$zmx_name") & +send_a=$! +(printf 'b\n' | zmx send "$zmx_name") & +send_b=$! +wait "$send_a" "$send_b" +printf 'c\n' | zmx send "$zmx_name" +zmx send "$zmx_name" "$(printf '\003')" +sleep 2 +zmx history "$zmx_name" | rg -q 'completed:c' +zmx kill "$zmx_name" +wait "$zmx_client" 2>/dev/null || true +set +e +lost_output=$(printf 'lost\n' | zmx send "$zmx_name" 2>&1) +lost_status=$? +set -e +test "$lost_status" -eq 0 +printf '%s\n' "$lost_output" | rg -qi 'unresponsive|not found' +test "$(zmx list --short | rg -c "^$zmx_name$" || true)" -eq 0 +``` + +## Process-group fixture + +Run `uv run python spikes/claude/process_group_probe.py`. It starts +`fake_process_tree.sh` in a new POSIX session, verifies the recorded parent and +child share the expected pgid, sends SIGINT to that group, escalates to TERM if +needed, and asserts both processes exit. This proves the supervision primitive; +the implementation's opt-in live scenario must repeat the descendant check with +a disposable Claude tool turn. diff --git a/spikes/claude/assert_probe.py b/spikes/claude/assert_probe.py new file mode 100644 index 0000000..c897290 --- /dev/null +++ b/spikes/claude/assert_probe.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Assert content-free Claude probe structures and hook logs.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +Event = dict[str, Any] + + +def load(path: Path) -> list[Event]: + return [json.loads(line) for line in path.read_text().splitlines() if line] + + +def responses(events: list[Event], hook: str) -> list[Event]: + return [ + event + for event in events + if event.get("subtype") == "hook_response" and event.get("hook_event") == hook + ] + + +def assert_hooks_paired(events: list[Event]) -> None: + started = {event["hook_id"] for event in events if event.get("subtype") == "hook_started"} + finished = {event["hook_id"] for event in events if event.get("subtype") == "hook_response"} + assert started + assert started == finished + + +def assert_processing(events: list[Event]) -> None: + assert_hooks_paired(events) + prompt = responses(events, "UserPromptSubmit") + assert prompt + assert all(event.get("exit_code") != 2 for event in prompt) + assert not any(event.get("blocking_decision") for event in prompt) + prompt_settled = max(event["sequence"] for event in prompt) + assert any( + event.get("type") == "assistant" and event["sequence"] > prompt_settled for event in events + ) + + +def assert_completed(events: list[Event]) -> None: + assert_processing(events) + assistants = [event["sequence"] for event in events if event.get("type") == "assistant"] + last_activity = max(assistants) + final_stops = [ + event for event in responses(events, "Stop") if event["sequence"] > last_activity + ] + assert final_stops + assert all(event.get("exit_code") != 2 for event in final_stops) + assert not any(event.get("blocking_decision") for event in final_stops) + stop_settled = max(event["sequence"] for event in final_stops) + results = [ + event + for event in events + if event.get("type") == "result" + and event.get("subtype") == "success" + and not event.get("is_error", False) + and event["sequence"] > stop_settled + ] + assert results + result_sequence = max(event["sequence"] for event in results) + assert any( + event.get("type") == "process_exit" + and event.get("exit_code") == 0 + and event["sequence"] > result_sequence + for event in events + ) + + +def assert_structure(mode: str, events: list[Event]) -> None: + if mode == "receipt": + assert_completed(events) + elif mode == "block-prompt": + assert_hooks_paired(events) + assert any(event.get("exit_code") == 2 for event in responses(events, "UserPromptSubmit")) + assert not any(event.get("type") == "assistant" for event in events) + assert not responses(events, "Stop") + elif mode == "continue-stop": + stops = responses(events, "Stop") + assert len(stops) >= 2 + assert any(event.get("blocking_decision") for event in stops[:-1]) + assert_completed(events) + elif mode == "fail": + assert any(event.get("exit_code") == 1 for event in responses(events, "UserPromptSubmit")) + assert_completed(events) + elif mode == "timeout": + assert any( + event.get("outcome") == "cancelled" for event in responses(events, "UserPromptSubmit") + ) + assert_completed(events) + elif mode == "preflight": + assert_hooks_paired(events) + assert any( + event.get("dispatch_preflight") and event.get("exit_code") == 0 + for event in responses(events, "SessionStart") + ) + assert not any(event.get("type") in {"assistant", "result"} for event in events) + assert any( + event.get("type") == "process_exit" and event.get("exit_code") == 0 for event in events + ) + elif mode == "interrupt": + assert_processing(events) + assert not responses(events, "Stop") + assert not any( + event.get("type") == "result" and event.get("subtype") == "success" for event in events + ) + assert any( + event.get("type") == "process_exit" and event.get("exit_code") == 130 + for event in events + ) + else: + raise SystemExit(f"unknown structure mode: {mode}") + + +def assert_duplicate(paths: list[Path]) -> None: + assert len(paths) == 3 + hook_events = load(paths[0]) + prompt_ids = [ + event["prompt_id"] + for event in hook_events + if event.get("hook_event_name") == "UserPromptSubmit" + and event.get("prompt_marker") == "duplicate" + ] + assert len(prompt_ids) == 2 and len(set(prompt_ids)) == 2 + assert_completed(load(paths[1])) + assert_completed(load(paths[2])) + + +def assert_aggregate_fixture(events: list[Event]) -> None: + retries = [event for event in events if event.get("replayed")] + assert len(retries) == 1 + original = next( + event + for event in events + if event.get("source_delivery_id") == retries[0]["source_delivery_id"] + and not event.get("replayed") + ) + assert original["ingest_id"] != retries[0]["ingest_id"] + stop_sources = { + event["source_delivery_id"] for event in events if event.get("hook_event") == "Stop" + } + assert len(stop_sources) == 2 + + +def assert_negative_fixtures(directory: Path) -> None: + for name in ( + "truncated-hooks", + "final-continuation", + "stop-exit-two", + "nonzero-exit", + ): + try: + assert_completed(load(directory / f"{name}.jsonl")) + except AssertionError: + continue + raise AssertionError(f"negative fixture unexpectedly completed: {name}") + + +def main() -> None: + if len(sys.argv) < 3: + raise SystemExit("usage: assert_probe.py MODE PATH [PATH ...]") + mode = sys.argv[1] + paths = [Path(value) for value in sys.argv[2:]] + if mode == "duplicate": + assert_duplicate(paths) + elif mode == "aggregate-fixture": + assert_aggregate_fixture(load(paths[0])) + elif mode == "negative-fixtures": + assert_negative_fixtures(paths[0]) + else: + assert len(paths) == 1 + assert_structure(mode, load(paths[0])) + + +if __name__ == "__main__": + main() diff --git a/spikes/claude/fake_process_tree.sh b/spikes/claude/fake_process_tree.sh new file mode 100755 index 0000000..de3dac7 --- /dev/null +++ b/spikes/claude/fake_process_tree.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +: "${DISPATCH_CLAUDE_PROCESS_FILE:?set DISPATCH_CLAUDE_PROCESS_FILE}" + +child="" +cleanup() { + trap - INT TERM EXIT + if [ -n "$child" ]; then + kill -TERM "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + fi + exit 0 +} +trap cleanup INT TERM EXIT + +sleep 300 & +child=$! +printf '%s %s\n' "$$" "$child" > "$DISPATCH_CLAUDE_PROCESS_FILE" +wait "$child" diff --git a/spikes/claude/fake_repl.sh b/spikes/claude/fake_repl.sh new file mode 100755 index 0000000..0e1de80 --- /dev/null +++ b/spikes/claude/fake_repl.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +trap 'printf "interrupted\r\n"' INT +printf 'ready\r\n' + +while IFS= read -r line; do + printf 'accepted:%s\r\n' "$line" + sleep 1 + printf 'completed:%s\r\n' "$line" +done diff --git a/spikes/claude/fixtures/aggregate-receipts.jsonl b/spikes/claude/fixtures/aggregate-receipts.jsonl new file mode 100644 index 0000000..bd824a8 --- /dev/null +++ b/spikes/claude/fixtures/aggregate-receipts.jsonl @@ -0,0 +1,8 @@ +{"sequence":1,"type":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000011","source_delivery_id":"00000000-0000-4000-8000-000000000021","ingest_id":"00000000-0000-4000-8000-000000000031","exit_code":0,"outcome":"success","dispatch_observer":true} +{"sequence":2,"type":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000011","source_delivery_id":"00000000-0000-4000-8000-000000000021","ingest_id":"00000000-0000-4000-8000-000000000032","exit_code":0,"outcome":"success","dispatch_observer":true,"replayed":true,"reducer_effect":"none"} +{"sequence":3,"type":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000012","source_delivery_id":"00000000-0000-4000-8000-000000000022","ingest_id":"00000000-0000-4000-8000-000000000033","exit_code":2,"outcome":"success","dispatch_observer":false} +{"sequence":4,"type":"result","subtype":"success","assistant_activity":false,"stop_occurrences":0,"reducer_state":"submission_observed"} +{"sequence":5,"type":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000013","source_delivery_id":"00000000-0000-4000-8000-000000000023","ingest_id":"00000000-0000-4000-8000-000000000034","exit_code":0,"outcome":"success","occurrence":1,"continued":true} +{"sequence":6,"type":"assistant","structural_only":true,"occurrence_after_stop":1} +{"sequence":7,"type":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000014","source_delivery_id":"00000000-0000-4000-8000-000000000024","ingest_id":"00000000-0000-4000-8000-000000000035","exit_code":0,"outcome":"success","occurrence":2,"continued":false} +{"sequence":8,"type":"result","subtype":"success","clean_exit":true,"reducer_state":"completed"} diff --git a/spikes/claude/fixtures/negative/final-continuation.jsonl b/spikes/claude/fixtures/negative/final-continuation.jsonl new file mode 100644 index 0000000..8fe8d39 --- /dev/null +++ b/spikes/claude/fixtures/negative/final-continuation.jsonl @@ -0,0 +1,7 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000111"} +{"sequence":2,"type":"system","subtype":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000111","exit_code":0,"outcome":"success"} +{"sequence":3,"type":"assistant"} +{"sequence":4,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000112"} +{"sequence":5,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000112","exit_code":0,"outcome":"success","blocking_decision":true} +{"sequence":6,"type":"result","subtype":"success"} +{"sequence":7,"type":"process_exit","exit_code":0} diff --git a/spikes/claude/fixtures/negative/nonzero-exit.jsonl b/spikes/claude/fixtures/negative/nonzero-exit.jsonl new file mode 100644 index 0000000..db84a27 --- /dev/null +++ b/spikes/claude/fixtures/negative/nonzero-exit.jsonl @@ -0,0 +1,7 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000121"} +{"sequence":2,"type":"system","subtype":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000121","exit_code":0,"outcome":"success"} +{"sequence":3,"type":"assistant"} +{"sequence":4,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000122"} +{"sequence":5,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000122","exit_code":0,"outcome":"success"} +{"sequence":6,"type":"result","subtype":"success"} +{"sequence":7,"type":"process_exit","exit_code":1} diff --git a/spikes/claude/fixtures/negative/stop-exit-two.jsonl b/spikes/claude/fixtures/negative/stop-exit-two.jsonl new file mode 100644 index 0000000..b269782 --- /dev/null +++ b/spikes/claude/fixtures/negative/stop-exit-two.jsonl @@ -0,0 +1,7 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000131"} +{"sequence":2,"type":"system","subtype":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000131","exit_code":0,"outcome":"success"} +{"sequence":3,"type":"assistant"} +{"sequence":4,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000132"} +{"sequence":5,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000132","exit_code":2,"outcome":"success"} +{"sequence":6,"type":"result","subtype":"success"} +{"sequence":7,"type":"process_exit","exit_code":0} diff --git a/spikes/claude/fixtures/negative/truncated-hooks.jsonl b/spikes/claude/fixtures/negative/truncated-hooks.jsonl new file mode 100644 index 0000000..3203f53 --- /dev/null +++ b/spikes/claude/fixtures/negative/truncated-hooks.jsonl @@ -0,0 +1,6 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000101"} +{"sequence":2,"type":"assistant"} +{"sequence":3,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000102"} +{"sequence":4,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000102","exit_code":0,"outcome":"success"} +{"sequence":5,"type":"result","subtype":"success"} +{"sequence":6,"type":"process_exit","exit_code":0} diff --git a/spikes/claude/fixtures/receipt-sequence.jsonl b/spikes/claude/fixtures/receipt-sequence.jsonl new file mode 100644 index 0000000..550d0ca --- /dev/null +++ b/spikes/claude/fixtures/receipt-sequence.jsonl @@ -0,0 +1,6 @@ +{"hook_event_name":"SessionStart","session_id":"00000000-0000-4000-8000-000000000001","source":"resume","keys":["cwd","hook_event_name","session_id","source","transcript_path"]} +{"hook_event_name":"UserPromptSubmit","session_id":"00000000-0000-4000-8000-000000000001","prompt_id":"00000000-0000-4000-8000-000000000002","prompt_marker":"message-1","permission_mode":"default","keys":["cwd","hook_event_name","permission_mode","prompt","prompt_id","session_id","transcript_path"]} +{"hook_event_name":"PermissionRequest","session_id":"00000000-0000-4000-8000-000000000001","prompt_id":"00000000-0000-4000-8000-000000000002","tool_name":"AskUserQuestion","permission_mode":"default","keys":["cwd","hook_event_name","permission_mode","prompt_id","session_id","tool_input","tool_name","transcript_path"]} +{"hook_event_name":"Notification","session_id":"00000000-0000-4000-8000-000000000001","prompt_id":"00000000-0000-4000-8000-000000000002","notification_type":"permission_prompt","keys":["cwd","hook_event_name","message","notification_type","prompt_id","session_id","transcript_path"]} +{"hook_event_name":"Stop","session_id":"00000000-0000-4000-8000-000000000001","prompt_id":"00000000-0000-4000-8000-000000000002","permission_mode":"default","stop_hook_active":false,"keys":["background_tasks","cwd","hook_event_name","last_assistant_message","permission_mode","prompt_id","session_crons","session_id","stop_hook_active","transcript_path"]} +{"hook_event_name":"SessionEnd","session_id":"00000000-0000-4000-8000-000000000001","prompt_id":"00000000-0000-4000-8000-000000000002","reason":"other","keys":["cwd","hook_event_name","prompt_id","reason","session_id","transcript_path"]} diff --git a/spikes/claude/fixtures/whitespace-block-raw.jsonl b/spikes/claude/fixtures/whitespace-block-raw.jsonl new file mode 100644 index 0000000..c7e2cb1 --- /dev/null +++ b/spikes/claude/fixtures/whitespace-block-raw.jsonl @@ -0,0 +1 @@ +{"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000141","exit_code":0,"outcome":"success","stdout":"{ \"decision\": \"block\", \"reason\": \"synthetic\" }"} diff --git a/spikes/claude/hook_block_prompt.sh b/spikes/claude/hook_block_prompt.sh new file mode 100755 index 0000000..5a0ac0c --- /dev/null +++ b/spikes/claude/hook_block_prompt.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Blocking sibling fixture: proves one successful observer is not aggregate acceptance. +exit 2 diff --git a/spikes/claude/hook_capture.sh b/spikes/claude/hook_capture.sh new file mode 100755 index 0000000..d53a023 --- /dev/null +++ b/spikes/claude/hook_capture.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +: "${DISPATCH_CLAUDE_PROBE_LOG:?set DISPATCH_CLAUDE_PROBE_LOG}" + +# Keep only routing/lifecycle facts. Prompt, transcript, tool input/output, cwd, +# and other content-bearing fields never reach the probe log. +jq -c ' + . as $event + | { + hook_event_name, + session_id, + prompt_id, + source, + reason, + notification_type, + tool_name, + permission_mode, + stop_hook_active, + prompt_marker: ( + ($event.prompt // "") + | if test("DISPATCH-PROBE:[A-Za-z0-9_-]+") + then capture("DISPATCH-PROBE:(?[A-Za-z0-9_-]+)").id + else null + end + ), + keys: ($event | keys | sort) + } + | with_entries(select(.value != null)) +' >> "$DISPATCH_CLAUDE_PROBE_LOG" diff --git a/spikes/claude/hook_continue_once.sh b/spikes/claude/hook_continue_once.sh new file mode 100755 index 0000000..49d2206 --- /dev/null +++ b/spikes/claude/hook_continue_once.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +: "${DISPATCH_CLAUDE_STOP_STATE:?set DISPATCH_CLAUDE_STOP_STATE}" + +if mkdir "$DISPATCH_CLAUDE_STOP_STATE" 2>/dev/null; then + printf '%s\n' '{"decision":"block","reason":"Continue once for receipt probe."}' +fi diff --git a/spikes/claude/hook_fail.sh b/spikes/claude/hook_fail.sh new file mode 100755 index 0000000..9ee94f7 --- /dev/null +++ b/spikes/claude/hook_fail.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Non-blocking observability failure fixture. It deliberately emits no content. +exit 1 diff --git a/spikes/claude/hook_preflight.sh b/spikes/claude/hook_preflight.sh new file mode 100755 index 0000000..0ba26f3 --- /dev/null +++ b/spikes/claude/hook_preflight.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +: "${DISPATCH_CLAUDE_PREFLIGHT_NONCE:?set DISPATCH_CLAUDE_PREFLIGHT_NONCE}" + +jq -cn --arg nonce "$DISPATCH_CLAUDE_PREFLIGHT_NONCE" \ + '{suppressOutput:true,_dispatch_preflight:{nonce:$nonce}}' diff --git a/spikes/claude/hook_timeout.sh b/spikes/claude/hook_timeout.sh new file mode 100755 index 0000000..0fa346a --- /dev/null +++ b/spikes/claude/hook_timeout.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Timeout fixture. Claude is configured to stop waiting before this exits. +sleep 2 +exit 0 diff --git a/spikes/claude/make_settings.sh b/spikes/claude/make_settings.sh new file mode 100755 index 0000000..5530727 --- /dev/null +++ b/spikes/claude/make_settings.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + printf 'usage: %s OUTPUT receipt|block-prompt|continue-stop|fail|timeout|preflight\n' "$0" >&2 + exit 2 +fi + +output=$1 +mode=$2 +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +capture=$root/hook_capture.sh +tmp=$output.tmp.$$ +trap 'rm -f "$tmp"' EXIT INT TERM +umask 077 + +base_hooks=$(jq -cn --arg command "$capture" ' + def command($value; $timeout): + {type:"command", command:$value, timeout:$timeout}; + { + SessionStart:[{hooks:[command($command; 5)]}], + UserPromptSubmit:[{hooks:[command($command; 5)]}], + Stop:[{hooks:[command($command; 5)]}], + StopFailure:[{hooks:[command($command; 5)]}], + SessionEnd:[{hooks:[command($command; 5)]}] + } +') + +case $mode in + receipt) + jq -cn --argjson hooks "$base_hooks" '{hooks:$hooks}' > "$tmp" + ;; + block-prompt) + jq -cn --argjson hooks "$base_hooks" \ + --arg command "$root/hook_block_prompt.sh" ' + ($hooks | .UserPromptSubmit[0].hooks += + [{type:"command",command:$command,timeout:5}]) + | {hooks:.} + ' > "$tmp" + ;; + continue-stop) + jq -cn --argjson hooks "$base_hooks" \ + --arg command "$root/hook_continue_once.sh" ' + ($hooks | .Stop[0].hooks += + [{type:"command",command:$command,timeout:5}]) + | {hooks:.} + ' > "$tmp" + ;; + fail) + jq -cn --argjson hooks "$base_hooks" --arg command "$root/hook_fail.sh" ' + ($hooks | .UserPromptSubmit[0].hooks += + [{type:"command",command:$command,timeout:5}]) + | {hooks:.} + ' > "$tmp" + ;; + timeout) + jq -cn --argjson hooks "$base_hooks" \ + --arg command "$root/hook_timeout.sh" ' + ($hooks | .UserPromptSubmit[0].hooks += + [{type:"command",command:$command,timeout:1}]) + | {hooks:.} + ' > "$tmp" + ;; + preflight) + : "${DISPATCH_CLAUDE_PREFLIGHT_NONCE:?set DISPATCH_CLAUDE_PREFLIGHT_NONCE}" + command="DISPATCH_CLAUDE_PREFLIGHT_NONCE=$DISPATCH_CLAUDE_PREFLIGHT_NONCE $root/hook_preflight.sh" + jq -cn --arg command "$command" ' + {hooks:{SessionStart:[{hooks:[ + {type:"command",command:$command,timeout:5} + ]}]}} + ' > "$tmp" + ;; + *) + printf 'unknown mode: %s\n' "$mode" >&2 + exit 2 + ;; +esac + +chmod 600 "$tmp" +mv "$tmp" "$output" +trap - EXIT INT TERM diff --git a/spikes/claude/process_group_probe.py b/spikes/claude/process_group_probe.py new file mode 100755 index 0000000..6e5a920 --- /dev/null +++ b/spikes/claude/process_group_probe.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Prove exact POSIX process-group ownership with a disposable fake tree.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import tempfile +import time +from pathlib import Path + +SCRIPT = Path(__file__).with_name("fake_process_tree.sh") + + +def running(pid: int) -> bool: + result = subprocess.run( + ["ps", "-o", "stat=", "-p", str(pid)], + check=False, + capture_output=True, + text=True, + ) + state = result.stdout.strip() + return result.returncode == 0 and bool(state) and not state.startswith("Z") + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="dispatch-claude-pgid-") as temp: + process_file = Path(temp) / "processes" + env = {**os.environ, "DISPATCH_CLAUDE_PROCESS_FILE": str(process_file)} + process = subprocess.Popen( + [str(SCRIPT)], + env=env, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + for _ in range(100): + if process_file.exists(): + break + time.sleep(0.02) + else: + raise RuntimeError("fake process tree did not start") + + parent, child = map(int, process_file.read_text().split()) + if parent != process.pid or os.getpgid(parent) != parent: + raise RuntimeError("parent identity/pgid mismatch") + if os.getpgid(child) != parent: + raise RuntimeError("child escaped the owned process group") + + os.killpg(parent, signal.SIGINT) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + os.killpg(parent, signal.SIGTERM) + process.wait(timeout=2) + + if running(parent) or running(child): + raise RuntimeError("owned process group did not exit") + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + + +if __name__ == "__main__": + main() diff --git a/spikes/claude/sanitize_stream.jq b/spikes/claude/sanitize_stream.jq new file mode 100644 index 0000000..46bf1e5 --- /dev/null +++ b/spikes/claude/sanitize_stream.jq @@ -0,0 +1,28 @@ +# Reduce Claude stream JSON immediately. Never retain message/model/tool content. +if .subtype == "hook_started" or .subtype == "hook_response" then + { + sequence: input_line_number, + type, + subtype, + session_id, + hook_id, + hook_event, + hook_name, + exit_code, + outcome, + dispatch_preflight: ((.stdout // "") | contains("_dispatch_preflight")), + blocking_decision: ( + ((.stdout // "") | fromjson? // {}) + | .decision == "block" + ) + } +elif .type == "assistant" then + {sequence: input_line_number, type, session_id} +elif .type == "result" then + {sequence: input_line_number, type, subtype, session_id, is_error} +elif .type == "system" and .subtype == "init" then + {sequence: input_line_number, type, subtype, session_id} +else + empty +end +| with_entries(select(.value != null and .value != false)) From 14c383b30ca464962e508a905518f6d2051cc6e4 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:09:08 -0400 Subject: [PATCH 3/9] test: cover Claude tool activity receipts --- spikes/claude/README.md | 2 ++ spikes/claude/assert_probe.py | 13 ++++++++++--- spikes/claude/fixtures/tool-only-processing.jsonl | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 spikes/claude/fixtures/tool-only-processing.jsonl diff --git a/spikes/claude/README.md b/spikes/claude/README.md index 7292432..73aa600 100644 --- a/spikes/claude/README.md +++ b/spikes/claude/README.md @@ -31,6 +31,8 @@ uv run python spikes/claude/assert_probe.py aggregate-fixture \ spikes/claude/fixtures/aggregate-receipts.jsonl uv run python spikes/claude/assert_probe.py negative-fixtures \ spikes/claude/fixtures/negative +uv run python spikes/claude/assert_probe.py receipt \ + spikes/claude/fixtures/tool-only-processing.jsonl jq -cf spikes/claude/sanitize_stream.jq \ spikes/claude/fixtures/whitespace-block-raw.jsonl | jq -e '.blocking_decision == true and (has("stdout") | not)' diff --git a/spikes/claude/assert_probe.py b/spikes/claude/assert_probe.py index c897290..82aa98c 100644 --- a/spikes/claude/assert_probe.py +++ b/spikes/claude/assert_probe.py @@ -30,6 +30,13 @@ def assert_hooks_paired(events: list[Event]) -> None: assert started == finished +def is_provider_activity(event: Event) -> bool: + return event.get("type") == "assistant" or ( + event.get("subtype") == "hook_started" + and event.get("hook_event") in {"PreToolUse", "PostToolUse"} + ) + + def assert_processing(events: list[Event]) -> None: assert_hooks_paired(events) prompt = responses(events, "UserPromptSubmit") @@ -38,14 +45,14 @@ def assert_processing(events: list[Event]) -> None: assert not any(event.get("blocking_decision") for event in prompt) prompt_settled = max(event["sequence"] for event in prompt) assert any( - event.get("type") == "assistant" and event["sequence"] > prompt_settled for event in events + is_provider_activity(event) and event["sequence"] > prompt_settled for event in events ) def assert_completed(events: list[Event]) -> None: assert_processing(events) - assistants = [event["sequence"] for event in events if event.get("type") == "assistant"] - last_activity = max(assistants) + activities = [event["sequence"] for event in events if is_provider_activity(event)] + last_activity = max(activities) final_stops = [ event for event in responses(events, "Stop") if event["sequence"] > last_activity ] diff --git a/spikes/claude/fixtures/tool-only-processing.jsonl b/spikes/claude/fixtures/tool-only-processing.jsonl new file mode 100644 index 0000000..f696f11 --- /dev/null +++ b/spikes/claude/fixtures/tool-only-processing.jsonl @@ -0,0 +1,8 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000151"} +{"sequence":2,"type":"system","subtype":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000151","exit_code":0,"outcome":"success"} +{"sequence":3,"type":"system","subtype":"hook_started","hook_event":"PreToolUse","hook_id":"00000000-0000-4000-8000-000000000152"} +{"sequence":4,"type":"system","subtype":"hook_response","hook_event":"PreToolUse","hook_id":"00000000-0000-4000-8000-000000000152","exit_code":0,"outcome":"success"} +{"sequence":5,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000153"} +{"sequence":6,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000153","exit_code":0,"outcome":"success"} +{"sequence":7,"type":"result","subtype":"success"} +{"sequence":8,"type":"process_exit","exit_code":0} From e66358d4684e8b14ba8a9cf836f1fa8e4a5c6047 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:41:22 -0400 Subject: [PATCH 4/9] docs: correct Claude coexistence architecture --- .../RETRO.md | 113 +++++-- ...23-provider-event-log-and-history-index.md | 20 +- ...control-uses-resume-processes-and-hooks.md | 112 +++++-- docs/development/claude-provider-plan.md | 297 ++++++++++++++---- .../claude-control-plane-verification.md | 186 ++++++++--- spikes/claude/README.md | 38 ++- spikes/claude/assert_probe.py | 196 +++++++++++- .../fixtures/agent-view-cockpit-plan.jsonl | 15 + spikes/claude/fixtures/capability-policy.json | 12 + .../fixtures/coexistence-outcomes.jsonl | 17 + .../persistent-owner-completion.jsonl | 6 + tests/fixtures/test_claude_research.py | 48 +++ 12 files changed, 895 insertions(+), 165 deletions(-) create mode 100644 spikes/claude/fixtures/agent-view-cockpit-plan.jsonl create mode 100644 spikes/claude/fixtures/capability-policy.json create mode 100644 spikes/claude/fixtures/coexistence-outcomes.jsonl create mode 100644 spikes/claude/fixtures/persistent-owner-completion.jsonl create mode 100644 tests/fixtures/test_claude_research.py diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index 4c04e00..7fdb9a8 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -1,8 +1,8 @@ # Execution Retro: Claude Control-Plane Research Date started: 2026-07-15 -Date finalized: 2026-07-15 -Status: Executing baseline and supported-surface verification +Date finalized: pending hosted CI and PR thread closure +Status: Local gates and post-correction reviews clean; hosted closure pending Spec: `.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md` Goal: `.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md` Prompt: `.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md` @@ -13,7 +13,9 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Objective: verify Claude session-control semantics and produce an implementation-ready Dispatch provider plan. - Completion horizon: `ready-pr`. - Authority: isolated low-cost research, scoped docs/spikes/ADRs/tracker, commit/push/draft/ready PR; no production implementation, merge, release, publish, live settings mutation, private endpoint, credential access, or existing-session messaging. -- Current state: delegated execution active on `docs/claude-control-plane-research`; baseline ledger complete and supported-surface audits in progress. +- Current state: transport recommendation corrected after coexistence probes; + supported seamless human-plus-Dispatch input is a proven blocker, while an + exclusive persistent stream owner is verified for headless control. - Known baseline: Dispatch 0.10.0 observes Claude but controls only Codex; Claude Code 2.1.210 exposes several candidate direct surfaces; zmx 0.6.0 raw send is fire-and-forget. ## Readiness @@ -71,7 +73,7 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Interrupt/recovery: started a resumed turn that invoked a 30-second Bash sleep, observed correlated `UserPromptSubmit` and tool hooks, then sent SIGINT to the owned CLI process. It exited 130 without a new `Stop`; a fresh process resumed the same UUID and completed a later message. This matches the documented rule that user interruption does not emit `Stop`. - Concurrent sends: two simultaneous `--resume --print` processes both succeeded. Each `UserPromptSubmit` received a distinct provider `prompt_id`, and each `Stop` carried the matching `prompt_id`; submission order differed from launch order. Provider prompt IDs give exact cycle joins, but Dispatch must enforce a durable single-writer lease for ordered delivery. - Duplicate send: two simultaneous processes carrying the same synthetic marker produced two distinct provider `prompt_id` values and two turns. Claude provides no idempotency key at this surface; ambiguous retries must reconcile receipts and never resend blindly. -- Hook failure: an added exit-1 `UserPromptSubmit` observability hook produced a hook response with exit code 1 while the Dispatch capture hook succeeded and the turn completed. A separate two-second hook with a one-second timeout was reported as `outcome=cancelled`, exit 1; the prompt still completed. Missing/failed/timed-out hook settlement means unknown processing, not rejection. +- Hook failure: an added exit-1 `UserPromptSubmit` observability hook produced a hook response with exit code 1 while the Dispatch capture hook succeeded and the turn completed. A separate two-second hook with a one-second timeout was reported as `outcome=cancelled`, exit 1; the prompt still completed. Once every sibling reaches terminal settlement, none blocks, and owned activity follows, terminal nonblocking failures permit processing evidence but degrade hook health. A missing/nonterminal sibling leaves acceptance unknown; neither outcome is rejection. - Agent View attention: a named disposable `--bg` session returned short management ID `518b912b` and full UUID `518b912b-…`; its metadata reached `state=blocked`, `status=waiting`, `waitingFor=permission prompt`. Sanitized hooks joined `UserPromptSubmit`, `PermissionRequest(tool=AskUserQuestion)`, `Notification(type=permission_prompt)`, and `Stop` by one `prompt_id`. - Agent View attach: `claude attach` accepted the synthetic answer, completed, detached with Ctrl-Z without stopping, then accepted a second attached prompt with a new `prompt_id` and matching `Stop`. The shell has lifecycle commands but no non-interactive reply command, so Agent View is a human supervision surface rather than the default Dispatch message transport. - Agent View restart: a second disposable background session completed, was stopped (and disappeared from the default active JSON list), then `respawn` restored the same full UUID. Both background entries were stopped and removed; `--all`/cwd-filtered inventory returned zero matching rows. @@ -79,10 +81,31 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - zmx fake target: in isolated mode-0700/0600 directories, concurrent raw sends reached the synthetic target in `b` then `a` order despite `a` being launched first. A Ctrl-C raw send returned success but did not prevent `completed:c`. After killing the session, a send printed an unresponsive-session error yet exited 0. The isolated zmx inventory returned to zero. - zmx security blocker: tagged 0.6.0 source hard-codes debug logging and logs PTY input bytes in recoverable hex. Even private 0700/0600 directories retain prompt bytes. Production Claude-over-zmx is a product/security decision and is excluded from the recommended first transport until logging can be disabled/redacted in a pinned supported build. +2026-07-15 - Human coexistence architecture challenge +- Ordinary TUI plus external resume was repeated with isolated setting sources. The external process completed with normal hooks/result/exit while the TUI stayed attached, but the TUI displayed no external activity and explicitly lacked that marker on its next successful turn. After TUI exit, another fresh resume saw the TUI branch and still lacked the external marker. A first run's post-external human turn ended in `StopFailure`/context-limit failure. This disproves coherent shared ownership despite process and receipt success. +- Agent View background ownership behaved coherently and exclusively. Its pid remained alive at `done/idle`; external resume exited 1 both detached and human-attached with “currently running as a background agent.” The attached human completed a later turn. After detach and explicit Agent View stop, fresh resume succeeded. +- A persistent stream-JSON owner completed multiple framed messages and terminal per-message results without process exit. A concurrent external resume also returned success, but the continuing owner could not see the external marker. Persistent ownership is therefore the recommended headless base only under an exclusive owner lease. +- Transport decision: reject fresh resume per turn as the default. Agent View is supervision/exclusive handoff only. zmx 0.6.0 remains blocked by raw-input logging plus missing ACK/order guarantees. Seamless attached-human plus Dispatch send is `blocked`; DIS-54 must prove a supported Agent View send control or a safe pinned single-PTY owner before enablement. +- Evidence retained only as content-free outcome facts in `coexistence-outcomes.jsonl`; synthetic temp output was not promoted. Existing sessions remained untouched. +- Isolation surprise: the Agent View launch used explicit per-session settings, + empty setting sources, and a strict empty MCP config, but + `~/.claude/settings.json` changed at the launch timestamp (baseline hash prefix + `52a8b8fe`, post-launch `46831318`; post-launch size 3,862 bytes). Contents were + not read and the file was not restored or edited. The repo-local settings hash + remained `378cd942`. Coordinator froze further Agent View launch/mutation probes. + +2026-07-15 - Crew-derived Agent View cockpit correction +- Read-only inspection of `/Users/mg/Developer/outfitter/crew` found a concrete guarded quick-reply path against Claude Agent View. The committed operating lesson records live literal-space behavior; the current worktree strengthens selection with roster/row resolution, home normalization, detail-title verification, return/reselection, `❯ reply` verification, then type/submit. Crew explicitly classifies the path as UI automation, not delivery RPC. +- Reframed zmx as the persistent terminal substrate hosting one `claude agents` cockpit, not as the Claude receipt protocol and not the preferred per-worker TUI. Humans attach/detach from the same cockpit while Agent View retains the background owner. +- Installed zmx 0.6.0 remains insufficient. DIS-54 must add/pin bounded VT snapshot with monotonic revision, named keys, serialized conditional multi-input ACK, atomic payload-plus-Enter, short automation lease, nonzero overflow/loss, and complete input-log redaction. +- Concurrent-human rule: any human input changes the VT revision; every navigation write is conditional, and the final payload transaction aborts before content if the revision changed. Screen liveness and write ACK remain transport facts only. +- Receipt blocker remains: Agent View quick reply has not exposed all sibling prompt-hook settlements plus owned provider activity to Dispatch without raw transcript access. Transcript mtime/size and cockpit state are corroboration, not acceptance. The preferred design is therefore identified but DIS-54 stays open. +- No further Agent View process was launched because of the previously observed global-settings mutation. Added a sanitized executable cockpit-plan fixture and repository-gated negative capability test instead. + 2026-07-15 - Aggregate receipts, transport/security review, and preflight milestone - Transport review round 1 scored 2/5 with 3 P1 and 4 P2 findings. Security/product review round 1 scored 2/5 with 4 P1 and 2 P2 findings. Reports are local scratch under `tmp/reviews/transport/round-1.json` and `tmp/reviews/security-product/round-1.json`. - Blocking sibling prompt hook: the Dispatch observer completed, a sibling `UserPromptSubmit` hook exited 2, no assistant activity or Stop occurred, yet the CLI emitted result subtype success. Therefore observer success and result success do not prove acceptance; processing requires aggregate prompt-hook settlement plus owned-stream assistant/tool activity. -- Continuing sibling Stop hook: one prompt produced two Stop occurrences with the same provider prompt ID, separated by further assistant activity. Completion requires the final Stop hook set to settle without continuation, terminal result success, and clean owned-process exit. +- Continuing sibling Stop hook: one prompt produced two Stop occurrences with the same provider prompt ID, separated by further assistant activity. Completion requires the final Stop hook set to settle without continuation and terminal per-message result success; clean exit is required when the persistent owner terminates. - Stream structure: `hook_started`/`hook_response` share CLI `hook_id`; raw hook payloads do not. Every ingest gets a daemon delivery ID, while repeated semantic Stop occurrences remain distinct. - Retry correction: a retry is safe only when Dispatch proves no stdin frame write began. Any transport loss after a possible write is acceptance-indeterminate and blocks automatic drain. - Interrupt correction: transport attempt state and provider completion state are separate. SIGINT after processing starts proves transport interruption only; provider completion remains unknown. Explicit operator abandonment may release Dispatch's queue while preserving that unknown fact. @@ -91,7 +114,10 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Privacy correction: removed durable prompt SHA-256 from the planned schema; the one-writer lease, session UUID, generation, and provider prompt ID correlate without adding a stable content fingerprint. - No-message preflight: a disposable stream-input process with EOF emitted a successful Dispatch SessionStart hook response carrying the current nonce before any prompt frame. Sibling hook stdout was reduced immediately and not retained. This supports abort-before-write when managed settings suppress the hook channel. - Process ownership fixture: `uv run python spikes/claude/process_group_probe.py` verified a POSIX `start_new_session` parent/child pgid and whole-group SIGINT/TERM cleanup. A live Claude descendant check remains an implementation scenario gate. -- Cleanup: review temp repo/settings removed, matching processes zero, settings hashes unchanged, and no existing session was touched. +- Cleanup at this pre-coexistence milestone: review temp repo/settings removed, + matching processes zero, settings hashes unchanged, and no existing session was + touched. The later Agent View challenge produced the separately recorded + product-side user-settings mutation. ``` ## Experiment Ledger @@ -114,6 +140,11 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | hook-3 | Does one Stop prove completion? | Sibling Stop hook continued exactly once | Claude Code 2.1.210 | No; two Stop cycles shared one prompt ID with assistant activity between | high | aggregate fixture + research doc | process exited | | hook-4 | Can Dispatch prove hook presence before message write? | SessionStart nonce hook, stream input with immediate EOF | Claude Code 2.1.210 | Yes; owned hook response carried nonce before any user frame | high | preflight fixture + README | process exited; no prompt | | proc-1 | Can a POSIX owner terminate an exact descendant group? | `start_new_session`, pid/pgid checks, SIGINT/TERM fake tree | local POSIX/Python | Yes; parent and child exited | high for primitive | executable spike | temp removed | +| coexist-1 | Does an attached ordinary TUI stay coherent during external resume-send? | Isolated TUI turn, external resume completion, later human turn, post-exit resume visibility check | Claude Code 2.1.210 | No; TUI stayed alive but did not inherit/display external turn; later resume followed TUI branch without external marker | high | coexistence fixture + research doc | TUI exited; temp removed | +| coexist-2 | Does Agent View preserve ownership during external resume-send? | Background owner at done/idle, retry detached and attached, human turn, stop then resume | Claude Code 2.1.210 | Yes by exclusion: live owner rejected resume exit 1; attached human continued; post-stop resume succeeded | high | coexistence fixture + research doc | stopped/removed | +| coexist-3 | Can one persistent stream owner carry multiple turns and share with external resume? | Two framed owner messages, concurrent resume, later owner visibility check | Claude Code 2.1.210 | Multi-turn owner verified; shared ownership failed because external turn was absent from continuing owner | high | coexistence fixture + research doc | owner exited; temp removed | +| cockpit-1 | Is there a target-safe path into the existing Agent View owner? | Read Crew quick-reply implementation, committed live lessons, and current identity guards | Crew commit `4a24fdb` + dirty worktree | Yes as UI composition: exact row, detail/title, return/reselect, literal-space reply, reply-prompt guard, submit | medium-high; direct Dispatch/zmx proof pending | cockpit plan fixture + provider plan | read-only; no process launch | +| cockpit-2 | Can installed zmx safely implement the cockpit route? | Compare zmx 0.6.0 help/source/fake failures to Crew console needs | zmx 0.6.0 | No as shipped: lacks revisioned screen, named/conditional atomic input, reliable ACK/errors, and redaction | high | transport table + DIS-54 | no zmx mutation | ## Source / Version Ledger @@ -123,10 +154,11 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | Claude sessions | 2026-07-15 rolling docs | scoping, resume/fork/name, concurrency, retention | reconciled with probes | | Claude Agent View | 2026-07-15 research preview + installed 2.1.210 | supervisor, IDs/states, attach/stop/respawn/rm, worktrees | reconciled with probes | | Claude hooks | 2026-07-15 rolling docs + installed events | receipt/attention schemas and failure semantics | reconciled with probes | -| Claude settings | 2026-07-15 rolling docs | precedence/merge and no-mutation strategy | verified by composition/hash guard | +| Claude settings | 2026-07-15 rolling docs | precedence/merge and no-mutation strategy | print-mode composition verified; Agent View violated no-global-mutation expectation despite isolation flags | | Claude Remote Control | 2026-07-15 research preview | explicit external product boundary | documented only; no live mutation | | zmx docs and tagged v0.6.0 source | 2026-07-15 / 0.6.0 | PTY/send/security semantics | reconciled with isolated fake target | | Dispatch source/ADRs | commit 06ae574 baseline | provider/storage/handler/selector seams | audited | +| Crew quick-reply implementation and operating lessons | local `4a24fdb` plus current uncommitted identity hardening | target-safe Agent View UI route and live literal-space behavior; UI send is not receipt | reconciled into DIS-54 candidate; no Dispatch/zmx live proof | ## Cleanup Audit @@ -135,8 +167,16 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Agent View: both created short IDs stopped and removed; --all/cwd-filtered matching count = 0. - zmx: isolated namespace matching count = 0; pre-existing default namespace count remained 1 and was untouched. - Processes: matching disposable process count = 0. -- Temp repository/settings/logs: owned /tmp/dispatch-claude-control-9ae658fe-... root removed. -- Settings: user hash remained 52a8b8fe...; repository-local hash remained 378cd942...; sizes/mtimes and absent paths unchanged. +- Temp repository/settings/logs: both owned `/tmp/dispatch-claude-control-...` + and `/tmp/dispatch-claude-coexist-...` roots removed. +- Settings before coexistence challenge: already captured digest prefixes were + 52a8b8fe... for the user file and 378cd942... for the repository-local file. +- Settings after Agent View coexistence launch: Claude changed the already + captured user-file metadata at the launch timestamp to digest prefix 46831318 + and size 3,862 bytes despite explicit per-session settings, empty setting + sources, and strict empty MCP. Contents were not inspected or retained, and + research did not restore or edit the file. The already captured + repository-local digest prefix remained 378cd942.... - Existing Agent View entries and default zmx session were never opened, read, messaged, attached, interrupted, renamed, stopped, or removed. - Claude's documented local transcript retention remains provider-owned. The research did not locate, read, or manually delete transcript files; Agent View rm is not transcript delete. ``` @@ -149,11 +189,12 @@ No row remains pending or unknown. Full semantics and citations are in | Operation/capability | Status | Primitive/composition | Preconditions | Acceptance/completion receipts | Failure/recovery and consequence | Evidence/version/confidence | | --- | --- | --- | --- | --- | --- | --- | | durable identity | verified | caller-chosen full UUID | correct project/worktree; provider-qualified route | same UUID in stream/hooks | typed stale/not-found; names/short IDs never authority | observed 2.1.210, high | -| new | verified | `--session-id UUID --print` | persist lane/message; hook preflight; no frame written | aggregate prompt settlement + activity; final settled Stop cycle + result/exit | retry only when no frame write began | observed 2.1.210, high | -| owned resume-for-send | verified | fresh Dispatch-owned `--resume UUID` | owned identity; correct cwd; no active attempt | `SessionStart(resume)` then aggregate receipts | typed stale/not-found; never `--continue` | observed/docs 2.1.210, high | +| new | verified | headless qualifier: exclusive persistent `--session-id UUID --print` stream owner | persist lane/message; hook preflight; no frame written; no other owner | aggregate prompt settlement + activity; final settled Stop cycle + terminal per-message result | retry only when no frame write began | observed 2.1.210, high | +| exclusive headless owner | verified | persistent stream-JSON process; post-exit `--resume UUID` replacement | owned identity; correct cwd; proven no other owner | aggregate prompt/activity receipts and terminal per-message result; clean exit on owner shutdown | second owner is rejected by Dispatch; ownership uncertainty blocks sends | observed/docs 2.1.210, high | | human Agent View attach | verified | human `attach SHORT_ID` | known Agent View entry and operator TTY | UI plus ordinary hooks; not Dispatch transport | no shell reply RPC | observed 2.1.210, high | +| preserve attached human while Dispatch sends | blocked | no safe supported shared-owner primitive | n/a | none sufficient | ordinary TUI/stream owner split history; Agent View rejects resume; zmx 0.6.0 fails privacy/receipt gates; DIS-54 | observed 2.1.210/0.6.0, high | | Dispatch attach unmanaged ordinary UUID | unsupported | none proven | n/a | none | do not grant writable authority from UUID alone | surface audit, high | -| send | verified | one serialized fresh resume process | owned identity; preflight; one-writer lease | processing after terminal non-blocking hook settlement/activity; completion after final Stop settlement/result/exit | possible-write loss is indeterminate; never auto-retry | observed 2.1.210, high | +| headless send | verified | one framed message at a time through exclusive persistent stream owner | owned identity; preflight; owner and one-writer leases | processing after terminal non-blocking hook settlement/activity; completion after final Stop settlement/terminal per-message result | possible-write loss is indeterminate; never auto-retry; never resume beside a live owner | observed 2.1.210, high | | steer during active turn | unsupported | no documented print-process RPC | n/a | none | typed unsupported; do not relabel queue | official/local audit, high | | durable queue/readiness | product-decision | Dispatch queue | explicit root allowlist; terminal receipt; healthy hooks; no attention/background work | next aggregate receipt sequence | uncertainty blocks drain until operator resolution | evidence high, policy DIS-52 | | interject | product-decision | interrupt, audited abandon, then ordinary send | verified owned group; explicit abandon if processing possible | transport exit plus new message receipts | non-atomic; prior provider completion remains unknown | observed 2.1.210, high | @@ -168,7 +209,7 @@ No row remains pending or unknown. Full semantics and citations are in | user input/elicitation | product-decision | attention hooks + human Agent View response | known Agent View entry and human | shared prompt ID plus later aggregate completion | first slice observes only | observed 2.1.210, high | | structured output | verified | print `--json-schema` | opt-in request/schema | validated result plus aggregate completion | typed schema/provider failure | official/help 2.1.210, medium-high | | rich input/files/images | product-decision | provider resources/human paste | future local input contract | ordinary aggregate receipts | first slice text-only | official/help, medium | -| process restart/recovery | verified | fresh resume; Agent View respawn | persisted UUID/generation; no blind replay | resume lifecycle and later receipts | provider-partitioned recovery only | observed 2.1.210, high | +| process restart/recovery | verified | post-exit fresh resume; Agent View respawn | persisted UUID/generation; proven old-owner exit; no blind replay | resume lifecycle and later receipts | owner unknown blocks recovery until explicit reconciliation | observed 2.1.210, high | | duplicate/concurrent send | verified | provider creates independent turns | absent Dispatch one-writer/dedupe controls | distinct prompt IDs and receipt cycles | ambiguous retry duplicates; serialize before spawn | observed 2.1.210, high | | remote/mesh compatibility | product-decision | owning daemon executes; Remote Control separate | explicit external policy/config | owner receipts if enabled | relay auth/outage semantics require decision | official docs, high | @@ -188,13 +229,16 @@ No row remains pending or unknown. Full semantics and citations are in | 3 | full stack | `tmp/reviews/full-stack/round-3.json` | 5/5 | clean | 0 | all prior full-stack findings fixed; live Linear reconciled | | 5 | transport | `tmp/review-history/transport/round-5.json` | 4/5 | changes requested, fixed | 1 | structured block parsing and Stop exit-2 rejection | | 6 | transport | `tmp/reviews/transport/round-6.json` | 5/5 | clean | 0 | all TR-001 through TR-010 fixed | +| 7 | transport | `tmp/reviews/transport/round-6.json` (replaced with post-correction report) | 5/5 | clean | 0 | cockpit target/lease/revision ordering, receipts, exclusive fallback, loss recovery; zero P0-P3 | +| 4 | security/product | `tmp/reviews/security-product/round-3.json` (replaced with post-correction report) | 5/5 | clean | 0 | settings isolation, privacy/redaction, same-UID trust, human race and cleanup; zero P0-P3 | +| 4 | full stack | `tmp/reviews/full-stack/round-3.json` (replaced with post-correction report) | 5/5 | clean | 0 | persistent-owner completion, exact blocked projection, recursive content-free gate, boundary alignment; zero P0-P3 | ## Verification Log | Check | Scope | Result | Notes | | --- | --- | --- | --- | | packet prompt | `check-goal-prompt --no-placeholders` | passed | 3,994/4,000 characters | -| packet doctor | goal packet | expected review blockers before final reruns | packet structure otherwise valid | +| packet doctor before coexistence correction | goal packet | expected review blockers | packet structure otherwise valid | | shell fixtures | all `spikes/claude/*.sh` | passed | `sh -n` | | JSON fixtures/settings | tracked JSONL plus all generated settings modes | passed | jq parse; settings mode 0600 | | process group | `uv run python spikes/claude/process_group_probe.py` | passed | parent/child pgid and cleanup | @@ -202,9 +246,11 @@ No row remains pending or unknown. Full semantics and citations are in | probe assertions | aggregate replay + three negative completion fixtures | passed | hook pairing, continuation, and nonzero-exit failures enforced | | repository gate attempt 1 | `just check` | 691 passed, 1 timing failure | focused supervisor test immediately passed; unrelated tracked code unchanged | | repository gate attempt 2 | `just check` | 692 tests passed; package-content step raced | concurrent review gate rebuilt `dist`; standalone package check passed | -| repository gate | `just check` | full-stack reviewer passed | 692 tests plus build/package; final local rerun pending after transport review | -| final packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; review reports all 5/5 clean | -| final repository gate | `just check` | passed | Ruff, format, strict mypy, 692 passed/17 deselected, wheel/sdist and contents | +| pre-correction repository gate | `just check` | full-stack reviewer passed | 692 tests plus build/package; superseded by post-correction gate | +| post-correction focused fixtures | `uv run pytest -q tests/fixtures/test_claude_research.py` | passed | 3 tests: blocker policy/negative evidence, guarded cockpit plan, persistent-owner completion without process exit | +| post-correction packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; all reports 5/5 clean with zero P0-P2 | +| post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 695 passed/17 deselected, wheel/sdist and contents | +| hosted CI and PR threads | PR #92 | pending | final external closure gate after push | ## Prompt / Goal Alignment @@ -218,19 +264,36 @@ No row remains pending or unknown. Full semantics and citations are in | DIS-9 | In Progress | Description corrected to aggregate receipt findings; implementation order comment added; close only after ready research PR | | DIS-48 | Todo | Adjacent usage-capture lifecycle; not messaging transport | | DIS-49 | Todo | Provider selector/CLI shorthand issue retained; blocked by DIS-50 and related to later slices | -| DIS-50 | Backlog | High-priority vertical Claude walking skeleton; blocked by DIS-1/2/3 | +| DIS-50 | Backlog | High-priority exclusive-headless walking skeleton; blocked by DIS-1/2/3 and DIS-54 | | DIS-51 | Backlog | High-priority receipt reduction and generation recovery hardening; blocked by DIS-50 | | DIS-52 | Backlog | High-priority queue, attention, and destructive indeterminate-attempt resolution; blocked by DIS-50/51 | | DIS-53 | Backlog | Metadata-only Agent View evaluation; blocked by DIS-50/52; never grants authority | +| DIS-54 | Backlog | Preferred zmx-hosted Agent View cockpit prerequisite; Crew route identified; zmx transaction/redaction and aggregate receipt proof block DIS-50 enablement/acceptance | ## Final State -- Completion proof: durable UUID, cross-process send, aggregate prompt receipt, repeated Stop cycles, interrupt/resume, attention, second message, and preflight verified in disposable sessions. +- Completion proof: durable UUID, exclusive persistent multi-message stream, + aggregate prompt receipt, repeated Stop cycles, interrupt/post-exit resume, + attention, second message, preflight, and ownership conflict behavior verified + in disposable sessions. - Walking-skeleton proof: control primitive verified; production implementation intentionally not present. -- Capability verdict: every row resolved as verified, unsupported, or product-decision; no naked unknowns. +- Capability verdict: every row resolved as verified, unsupported, + product-decision, or blocked; seamless attached-human coexistence is the proven + DIS-54 blocker rather than a forced parity claim. - Implementation plan: provider boundary, additive identity migration, event ingress, receipts, supervision, settings, recovery, security, surfaces, tests, rollout, and issues settled. -- Review summary: transport round 6, security/product round 3, and full-stack round 3 are each 5/5 clean with zero P0-P2. -- Verification summary: packet, fixture, lint/type, sanitizer privacy, process-group, negative receipt checks, and final local `just check` pass; PR CI pending. -- Cleanup audit: research-created Agent View/zmx/process/temp resources removed; settings hashes unchanged; provider-retained transcripts not read or manually deleted. -- Remaining product decisions/blockers: queue/attention (DIS-52), Agent View metadata (DIS-53), selector surface (DIS-49), and optional capabilities; none block an honest DIS-50 plan. -- Recommended first implementation slice: DIS-50 vertical walking skeleton behind disabled config; no abstraction-only precursor. +- Review summary: post-coexistence transport round 7, security/product round 4, + and full-stack round 4 are each 5/5 clean with zero P0-P3. +- Verification summary: packet, fixture, lint/type, sanitizer privacy, + process-group, negative receipt checks, and post-correction local `just check` + pass with 695 tests; hosted PR CI and thread closure are pending. +- Cleanup audit: research-created Agent View/zmx/process/temp resources removed; + repository-local settings unchanged; Claude's Agent View launch mutated the + user settings file despite isolation flags, and research neither read nor + modified it; provider-retained transcripts were not read or manually deleted. +- Remaining product decisions/blockers: DIS-54 blocks Claude enablement and + DIS-50 acceptance; queue/attention (DIS-52), Agent View metadata (DIS-53), + selector surface (DIS-49), and optional capabilities remain ordered behind it. +- Recommended first slice: resolve DIS-54 around the Crew-derived persistent + zmx-hosted Agent View cockpit. The implementation-ready headless fallback is an + exclusive persistent stream owner with explicit human handoff, not fresh resume + per turn; neither path is enabled until its stated gates pass. diff --git a/docs/adrs/0023-provider-event-log-and-history-index.md b/docs/adrs/0023-provider-event-log-and-history-index.md index c94066d..dcc9ff4 100644 --- a/docs/adrs/0023-provider-event-log-and-history-index.md +++ b/docs/adrs/0023-provider-event-log-and-history-index.md @@ -46,7 +46,8 @@ assistant/tool activity. Exit 1 and timeout are fail-open, degraded-health outcomes; exit 2 or a blocking decision prevents processing. Claude assigns a `prompt_id` shared by `UserPromptSubmit` and repeatable `Stop` cycles. Completion requires the final Stop hook set to settle without continuation, followed by -terminal result success and clean owned-process exit. Those events are not Codex +terminal per-message result success. Clean process exit is required when an owner +generation terminates, not for every message. Those events are not Codex `LaneEvent`s, but they map naturally onto provider-neutral event and history records. @@ -126,11 +127,18 @@ Claude comes second: 3. Hook observations are combined with owned structured stream events before reduction into the same `message_receipts`, `lane_runtime_state`, and attention/inbox semantics used for Codex. -4. The initial transport is a serialized fresh resume/print subprocess per turn; - Claude-specific process details remain adapter-local, not cross-provider - history schema. -5. zmx 0.6.0 is excluded from the initial transport because raw sends are not - acknowledged and the installed version always logs PTY input bytes. +4. The preferred human-coexistent transport is a pinned, hardened zmx-hosted + Agent View cockpit using Claude's guarded quick-reply path. It remains blocked + behind DIS-54 until one live proof establishes a single coherent history, + target-safe revisioned input, and correlated aggregate receipts. +5. The verified headless fallback is one exclusive persistent stream-JSON owner. + A fresh resume process may replace it only after proven owner exit; it is not + a per-turn transport and does not claim transparent human coexistence. +6. zmx 0.6.0 as shipped is excluded because raw sends are not acknowledged and + PTY input bytes are always logged. DIS-54 may admit a pinned hardened build + with revisioned snapshots, named keys, conditional atomic serialized writes, + an automation lease, explicit loss signaling, and input-log redaction. A PTY + write acknowledgement is transport evidence, never a Claude receipt. Storage backend policy: diff --git a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md index 600bd09..8b59967 100644 --- a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md +++ b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md @@ -1,14 +1,14 @@ --- id: 0026 slug: claude-control-uses-resume-processes-and-hooks -title: Claude Control Uses Resume Processes and Hooks +title: Claude Control Preserves One Owner and Requires Aggregate Receipts status: proposed created: 2026-07-15 updated: 2026-07-15 owners: ['[galligan](https://github.com/galligan)'] --- -# ADR-0026: Claude Control Uses Resume Processes and Hooks +# ADR-0026: Claude Control Preserves One Owner and Requires Aggregate Receipts ## Context @@ -21,32 +21,70 @@ Control, hooks, interactive PTYs, and zmx. Disposable experiments proved that a caller-chosen UUID survives fresh-process resume; `UserPromptSubmit` and `Stop` share a provider `prompt_id`; an owned process can be interrupted and the UUID resumed; and concurrent/duplicate resume -processes create independent turns. Agent View provides durable human -supervision but no documented non-interactive shell reply. zmx 0.6.0 raw send is -unacknowledged, can exit zero after loss, and always logs PTY input bytes. +processes create independent turns. Later coexistence probes showed that this is +unsafe when another client remains attached: an ordinary TUI and a fresh resume +both completed but continued from different histories, and the external turn was +absent from both the TUI and a later resume after the TUI wrote again. A persistent +stream-JSON process completed multiple turns coherently while it remained the +exclusive owner, but a concurrent resume split from it in the same way. + +Agent View provides durable human supervision and rejects fresh resume while its +background owner lives, preserving coherent ownership. Crew separately proves a +guarded quick-reply UI route into that owner: exact row resolution, detail-title +verification, return/reselection, literal-space reply open, reply-prompt +verification, and submit. zmx 0.6.0 raw send is unacknowledged, can exit zero +after loss, and always logs PTY input bytes; it needs hardening before it can host +that cockpit for Dispatch. ## Decision -Implement the first Claude runtime as one serialized, owned -`claude --resume --print` subprocess per turn. New sessions use an -explicit `--session-id `. Dispatch persists identity and the message -envelope before spawn and never has more than one in-flight process per Claude -session. - -Add Dispatch receipt/attention hooks through a generated per-invocation -`--settings` file. Do not modify user or project settings, and do not replace or -assume exclusive ownership of existing hooks. +Prefer one persistent zmx-hosted `claude agents` cockpit for human-coexistent +control while Agent View retains background-worker ownership. Port Crew's guarded +quick-reply route onto revisioned VT snapshots and serialized conditional input. +Require exact roster/session identity and screen guards before every navigation +step. Make payload plus Enter one atomic acknowledged transaction; abort before +payload if human input changes the VT revision. zmx is terminal transport only, +not Claude receipt authority. + +This preferred route remains blocked behind DIS-54 until a pinned zmx build +provides bounded current-screen snapshots, named keys, monotonic revisions, +conditional atomic input ACK, nonzero loss/overflow errors, and complete input-log +redaction, and until Agent View exposes aggregate hook settlement plus owned +provider activity without raw transcript reads. + +Implement the verified headless fallback around one exclusive persistent +`claude --print --input-format stream-json --output-format stream-json` owner. +New sessions use an explicit `--session-id `; after a proven owner exit, +restart uses `--resume ` to create the next exclusive owner. Dispatch +persists identity and the message envelope before writing a frame and never has +more than one owner or in-flight message per Claude session. + +Do not represent the headless fallback as seamless human coexistence. Human attach +requires an explicit ownership handoff: stop the Dispatch owner, let the human TUI +be the sole owner, and require explicit hand-back after that TUI exits. Until a +supported shared-owner send primitive or a safe pinned single-PTY transport is +proven, preservation of an already attached human while Dispatch sends is a +blocking capability, not an implementation assumption. + +Add Dispatch receipt/attention hooks through generated settings without replacing +or assuming exclusive ownership of existing hooks. Print-mode composition did +not modify user/project settings, but an isolated Agent View launch changed the +user settings file despite isolation flags. Automated Agent View launch stays +disabled until a product isolation contract is proven. Receipt authority is aggregate: - Dispatch `UserPromptSubmit` observation -> submission observed only; -- all prompt hooks settled successfully plus first owned-stream assistant/tool - activity -> processing started; +- every sibling prompt hook reached a terminal settlement, none blocked, plus + first owned-stream assistant/tool activity -> processing started; nonblocking + exit-1/cancelled settlements degrade hook health but do not erase the owned + processing evidence; - each `Stop` with the same provider `prompt_id` -> one stop cycle observed; -- final Stop hook settlement without continuation, then terminal result success - and clean owned-process exit -> main response completed; +- final Stop hook settlement without continuation, then terminal per-message + result success -> main response completed; the owner may remain healthy for + the next frame; - `StopFailure` -> provider/API failure; -- owned process SIGINT/exit without `Stop` -> interrupted or completion unknown, +- owned process SIGINT/exit without final settlement -> interrupted or completion unknown, never completed. Hook failure/timeout is fail-open in Claude. Missing aggregate evidence is @@ -55,12 +93,12 @@ Dispatch observer succeeds, and a sibling Stop hook can continue a turn after a Stop observation. Process exit, stdin write, stream replay, terminal output, Agent View logs, zmx status, and scrollback are not receipts. -Keep Agent View as an optional metadata/human-supervision integration. Exclude -zmx 0.6.0 and Remote Control from the initial production transport. Reconsider -either only through a separate decision with current evidence and explicit +Keep Agent View as the preferred human-supervision/quick-reply owner. Exclude zmx +0.6.0 as shipped and Remote Control from production transport. DIS-54 may admit a +hardened pinned zmx cockpit only with current evidence and explicit security/product acceptance. -Human Agent View attach is distinct from Dispatch-owned `--resume`-for-send. +Human Agent View attach is distinct from Dispatch-owned stream ownership/resume. Unmanaged ordinary-session attach is unsupported in the first adapter because no content-free metadata validation primitive was proven. @@ -85,10 +123,13 @@ There is no silent fallback or forced Codex semantic parity. ### Positive -- Uses supported direct CLI primitives without private endpoints or a persistent - PTY dependency. +- Uses supported direct CLI/Agent View primitives without private endpoints; + the coexistence candidate adds one explicit persistent cockpit PTY. - Durable UUID and provider prompt ID give stable routing and exact cycle joins. -- Fresh processes simplify ownership, interrupt, cleanup, and daemon restart. +- Persistent stream ownership preserves coherent headless multi-turn state and + avoids the observed per-turn resume split when a second owner is present. +- Agent View cockpit control preserves the same background owner for both human + and Dispatch input instead of creating a resume competitor. - Hooks compose with operator settings and feed the existing provider event / receipt / attention substrate. - Single-writer ownership prevents the observed interleaving and duplicate-turn @@ -96,22 +137,30 @@ There is no silent fallback or forced Codex semantic parity. ### Tradeoffs -- Each turn pays process startup cost. +- Each owner generation/restart pays process startup cost, and a healthy owner + remains a long-lived supervised resource between turns. - There is no true active-turn steer or hidden context injection in the first adapter. - Hook failure can leave processing uncertain even when Claude continues. - Possible-write or processing-but-incomplete loss cannot be retried automatically. - Human input waits are observable before a scriptable response path exists. +- Seamless attached-human plus Dispatch send remains blocked until DIS-54 proves + hardened zmx transactions and aggregate receipt evidence. - Claude retains local session transcripts under its own retention policy; the supported Agent View `rm` operation is not transcript deletion. ## Alternatives considered -- **Persistent interactive PTY through zmx** — rejected for v1: no delivery ACK, +- **Fresh resume process per turn** — rejected as the default: safe only when no + other owner exists; ordinary TUI and stream-owner probes produced split-brain + histories rather than shared continuity. +- **zmx-owned worker TUI** — not preferred: it duplicates Agent View supervision; + zmx 0.6.0 also has no delivery ACK, silent loss/exit behavior, no idempotency/correlation, and mandatory raw-input logging in 0.6.0. -- **Agent View as the primary transport** — rejected for v1: strong supervisor - and human UI, but no documented scriptable shell reply operation. +- **zmx-hosted Agent View cockpit** — preferred pending DIS-54: Crew proves the + guarded UI route, while Dispatch still needs zmx transaction/redaction work and + aggregate receipt proof. - **Remote Control** — deferred: it introduces Anthropic relay, subscription, policy, availability, and multi-device semantics without a documented local RPC for Dispatch. @@ -126,6 +175,9 @@ There is no silent fallback or forced Codex semantic parity. - [Claude control-plane verification](../research/claude-control-plane-verification.md) - [Claude provider implementation plan](../development/claude-provider-plan.md) +- Crew `packages/core/src/cmux.ts::sendClaudeAgentsMessage` and + `docs/operating-lessons.md` at local commit `4a24fdb` plus current target-identity + hardening - [ADR-0002](0002-single-daemon-over-one-app-server.md) - [ADR-0006](0006-handler-context-and-di.md) - [ADR-0007](0007-normalized-internal-lane-events.md) diff --git a/docs/development/claude-provider-plan.md b/docs/development/claude-provider-plan.md index 3f9cd55..34ba972 100644 --- a/docs/development/claude-provider-plan.md +++ b/docs/development/claude-provider-plan.md @@ -1,16 +1,26 @@ # Claude provider implementation plan -Status: implementation-ready research plan; no production implementation +Status: implementation-ready provider boundary and headless fallback; +Crew-derived Agent View cockpit is the preferred coexistence candidate, blocked +on DIS-54 zmx/receipt proof; no production implementation Evidence: [`claude-control-plane-verification.md`](../research/claude-control-plane-verification.md) Decision: [ADR-0026](../adrs/0026-claude-control-uses-resume-processes-and-hooks.md) ## Outcome Add Claude as a fixed second execution provider beneath Dispatch's authored ops. -The first implementation is a vertical walking skeleton: one Claude lane can be -created with a chosen UUID, receive serialized messages through fresh resume -processes, persist structurally corroborated processing/completion receipts, be interrupted, -survive a Dispatch restart, and receive another message. +The preferred coexistence target preserves Claude's Agent View background owner +and drives its guarded quick-reply UI through one persistent zmx-hosted +`claude agents` cockpit. Humans attach/detach from that same cockpit; Dispatch +never creates a competing resume owner. zmx supplies terminal snapshots and +input transactions only. Claude hooks plus owned provider activity remain the +receipt authority. + +Installed zmx 0.6.0 cannot implement that safely as shipped, and Agent View has +not exposed the required aggregate receipt stream. DIS-54 must add/prove the +missing primitives before transparent control is enabled. The verified fallback +is an exclusive persistent stream-JSON headless owner with explicit human +handoff; it is not seamless coexistence. This is not a provider plugin framework. Codex remains the default. Unsupported Claude semantics are visible capabilities and typed errors, never Codex fallback @@ -22,22 +32,37 @@ or renamed approximations. with Codex and Claude runtimes. - Codex continues to use the existing single App Server client. Claude does not go through App Server. -- Claude v1 transport is one serialized `claude --resume --print` - subprocess per turn. New sessions use `--session-id `. -- Per-invocation `--settings` adds Dispatch hooks without modifying global or - project settings. Existing hooks continue to run. +- Claude's verified headless transport is one exclusive persistent `claude + --print` process using stream-JSON input/output. New owners use `--session-id + `; replacement owners use `--resume ` only after proven prior-owner + exit. +- Print-mode per-owner `--settings` adds Dispatch hooks without modifying global + or project settings. Existing hooks continue to run. Agent View launch did + mutate the user settings file despite isolation flags, so automated Agent View + launch remains disabled pending a product isolation contract. - A `UserPromptSubmit` observation is only submission evidence. Processing is - confirmed after every prompt hook settles successfully and the owned CLI - stream emits the first assistant/tool activity for that prompt. + confirmed after every sibling reaches terminal settlement, none blocks, and + the owned CLI stream emits the first assistant/tool activity for that prompt. + Terminal nonblocking failures degrade hook health; a missing/nonterminal + sibling leaves acceptance unknown. - A `Stop` observation is only a stop-cycle boundary. Completion requires the final Stop hook set to settle without a continuation, followed by a terminal - success result and clean owned-process exit with no intervening activity. + per-message success result with no intervening activity. The persistent owner + may remain healthy for the next frame. - Process/stdin success, stream replay, result text, Agent View logs, PTY scrollback, and zmx status are diagnostic only. -- zmx 0.6.0 is not a production transport because raw send is unacknowledged and - raw PTY input is always logged. -- Agent View is an optional human supervision/attention surface, not the first - programmatic send transport. +- zmx 0.6.0 is not production-ready because raw send is unacknowledged and raw + PTY input is always logged. DIS-54 may extend/pin zmx as the persistent cockpit + substrate; zmx never becomes Claude receipt authority. +- Agent View quick reply is the preferred human-coexistent send candidate. Crew + proves the guarded UI route, but Dispatch still needs pinned zmx and receipt + evidence before capability projection can mark it supported. +- Ordinary TUI plus external resume and persistent stream owner plus external + resume both split history. Agent View rejects resume while its owner lives. + Dispatch must never start a second owner merely because the first is idle. +- Human attach to the Agent View cockpit shares the same background owner. A + short revision-checked UI transaction must abort if concurrent human input + changes the cockpit state. Headless fallback uses exclusive handoff instead. - One provider session has one in-flight Dispatch send. Concurrency is rejected or queued before spawning a process. - Raw hook payloads and transcripts are not retained by default. @@ -157,18 +182,75 @@ Each capability reports: } ``` -Reasons are a bounded enum such as `provider_unsupported`, `attached_read_only`, -`message_in_flight`, `needs_attention`, `hook_unhealthy`, `stale_identity`, or -`provider_unavailable`. CLI and MCP derive the same structure. +Reasons are a bounded enum such as `provider_unsupported`, `transport_blocked`, + `attached_read_only`, `message_in_flight`, `needs_attention`, `hook_unhealthy`, + `stale_identity`, `owner_conflict`, `human_owner`, or `provider_unavailable`. +CLI and MCP derive the same structure. A research status of `blocked` projects as +`supported=false`, `available_now=false`, `reason=transport_blocked`; it is not +collapsed into `provider_unsupported`. Initial Claude capabilities: -- supported: new, owned resume/send, interrupt, live watch, permission observation, - structured print output; +- supported headlessly: new, exclusive stream send, post-exit resume, interrupt, + live watch, permission observation, structured print output; - composed/product-gated: queue, interject, history/tail, rename, user-input - response, rich input, remote control; + response, rich input, remote control, explicit human ownership handoff; - unsupported: active-turn steer, Codex context injection, archive/restore, - Codex goal mutation. + Codex goal mutation; seamless attached-human plus Dispatch send is blocked. + +## Agent View cockpit candidate + +Crew's `sendClaudeAgentsMessage` and operating lessons establish the target-safe +UI sequence against real Agent View: + +1. resolve the shared cockpit and the target from `claude agents --json` using + the full session UUID as authority; +2. normalize to the Agent View home/list and locate one exact visible row; +3. select it, open detail, and verify the intended title/session identity; +4. return to home and verify the same row remains selected; +5. send a literal text space—not a named Space key—and wait for both `❯ reply` + and `space to close`; +6. atomically type the payload and Enter; +7. require ordinary Claude acceptance/completion receipts before changing the + Dispatch attempt state. + +This is UI automation, not an RPC. The console provider must fail closed at every +screen guard and must never fall back to unguarded raw send. + +Dispatch should replace Crew's cmux primitives with a pinned zmx cockpit API: + +```python +class CockpitTransport(Protocol): + async def snapshot(self) -> VtSnapshot: ... # text + monotonic revision + async def transact( + self, + *, + expected_revision: int, + inputs: Sequence[NamedKey | TextInput], + lease: UILease, + ) -> InputAck: ... + async def attach_info(self) -> AttachState: ... +``` + +Required zmx work, all gated by DIS-54: + +- render a bounded current VT snapshot separately from scrollback/history; +- assign a monotonic revision to every screen/input state change; +- support named keys and text without shell encoding; +- serialize and acknowledge a conditional multi-input transaction only after the + bytes enter the PTY queue; +- make payload plus Enter one atomic batch; +- expose a short exclusive automation lease; any human input increments revision + and causes a stale transaction to abort before payload; +- disable raw-input logging by construction and test that logs contain no input + bytes; +- return nonzero typed loss/overflow errors; never silently drop queued input. + +An input ACK proves only the cockpit write. It never proves Claude acceptance or +completion. The installed Agent View path does not yet expose every sibling-hook +outcome plus owned assistant/tool activity as a content-free stream, so DIS-54 +must settle that receipt source. Transcript mtime/size and Agent View state are +corroboration only; raw transcript content remains out of bounds. ## Identity and registry migration @@ -245,15 +327,18 @@ For `new --provider claude`: 3. persist lane/runtime/message state before spawning; 4. create an owner-only runtime directory and generated settings file atomically; 5. spawn with `asyncio.create_subprocess_exec`, an explicit argv, bounded env, - cwd, `--session-id UUID`, `--print`, stream JSON, Haiku/default model as - configured, permission mode, and settings path; -6. write one framed message and close stdin; + cwd, `--session-id UUID`, `--print`, stream-JSON input/output, + Haiku/default model as configured, permission mode, and settings path; +6. keep stdin open under the daemon's exclusive owner lease and write one framed + message only after preflight; 7. stream bounded structural and hook-settlement events while the Dispatch hook provides content-minimized observations; -8. record process exit independently from provider receipt state. +8. after per-message completion, retain the healthy owner for the next serialized + frame; record owner exit independently from provider receipt state. -For later sends, replace `--session-id` with `--resume UUID`. Never use -`--continue` for a managed lane. +Later sends use the same owner. Only after the exact prior owner is proven gone +may recovery start a new process with `--resume UUID`. Never use `--continue` for +a managed lane and never use resume as a second writer. ### Process ownership @@ -276,6 +361,11 @@ is gone after escalation. The opt-in Claude scenario repeats the descendant chec against a tool-spawning turn before this provider can be enabled. Do not signal Agent View supervisor processes or user-launched Claude sessions. +An idle prompt does not release ownership. A human TUI request must enter an +explicit handoff state, stop the Dispatch owner, and block all Dispatch sends +until the operator explicitly returns ownership. Because an unmanaged ordinary +TUI has no content-free liveness/ownership primitive, hand-back cannot be inferred +from UUID or transcript state. ### Restart recovery @@ -285,19 +375,37 @@ App Server supervisor. Claude recovery: 1. invalidates event nonces from prior daemon generations; 2. reconciles persisted pid/pgid/start identity without reading transcripts; 3. marks missing prior processes stopped; -4. preserves session UUIDs as resumable; -5. keeps `processing_started` without completion as `completion_unknown`; -6. resets queued claims but drains only after the runtime is confirmed ready; -7. never replays an ambiguous message automatically. - -The next explicit send resumes the UUID in a new generation. A future optional -Agent View adapter may inspect metadata, but it is not required for v1 recovery. +4. classifies a still-live prior Dispatch stream process whose pipes were lost as + `detached_owned_generation`, never ready or reattachable; +5. if that detached identity is exact, terminate its verified process group, + preserve any active attempt as indeterminate, await exit, and only then allow + a replacement resume owner; if identity cannot be proven, quarantine the lane + and do not signal or resume; +6. preserves session UUIDs as resumable; +7. keeps `processing_started` without completion as `completion_unknown`; +8. resets queued claims but drains only after the runtime is confirmed ready; +9. never replays an ambiguous message automatically. + +Only after recovery proves the old owner gone may the next explicit send resume +the UUID in a new generation. If an ordinary human owner may exist, state is +`owner_unknown` and send remains blocked until explicit reconciliation. Agent +View metadata can prove its background owner exists, but cannot authorize a +Dispatch send into that owner unless the guarded cockpit capability is enabled. + +For the cockpit route, daemon or zmx loss does not stop Agent View background +workers. Restart the cockpit, re-resolve the full session UUID from the roster, +and repeat every home/detail/return/reply guard. A UI transaction lost after any +possible write is `frame_maybe_written`; never replay it. If a human attach or +keystroke changes the VT revision, abort before payload and return an +operator-visible `cockpit_changed` conflict. ## Hook/settings strategy -Generate one owner-only settings file per active process. `--settings` merges it -above user/project/local settings without modifying them. The file adds command -hooks for the minimum receipt/attention set: +For each Dispatch-owned print/stream owner, generate one owner-only settings file. +On that surface, `--settings` merged it above user/project/local settings without +modifying them. This observation does not extend to Agent View: its isolated +launch mutated user settings, so automated Agent View launch remains disabled. +The print-owner file adds command hooks for the minimum receipt/attention set: - `SessionStart`, `SessionEnd`; - `UserPromptSubmit`; @@ -394,7 +502,9 @@ Correlation: 5. each `Stop`/`StopFailure` joins on session + generation + `prompt_id`; repeated Stop deliveries are retained as ordered occurrences, not deduplicated; 6. completion requires the last Stop occurrence, settlement of all hooks for that - cycle without a continuation, terminal success result, and clean process exit; + cycle without a continuation, and terminal per-message success result; an + unexpected owner exit is a separate transport fact, while orderly shutdown + must still exit cleanly; 7. late events from dead generations are retained as diagnostic provider events but cannot mutate the active attempt. @@ -421,7 +531,8 @@ scrollback as readiness. Claude is ready only when: -- no active owned process/attempt exists; +- exactly one healthy exclusive Dispatch owner exists and no message attempt is + active, or a replacement owner can be started after proven prior-owner exit; - the last attempt is terminal; - no unresolved permission/elicitation attention blocks the lane; - hook health meets policy; @@ -492,7 +603,7 @@ It is not active-turn steer and is not atomic. | --- | --- | | `new` | supported with explicit UUID and first serialized print process | | `attach` | unmanaged ordinary-session attach is unsupported in v1: no content-free metadata validation primitive was proven; human Agent View attach is a separate verified UI | -| `send` | supported, one process/attempt, durable request ID | +| `send` | supported headlessly through one exclusive persistent owner; durable request ID; blocked during human/unknown ownership | | `steer` | typed unsupported | | `queue` | Dispatch-owned, provider-confirmed readiness | | `interject` | disabled until product semantics accepted | @@ -545,8 +656,9 @@ permission_mode = "default" hook_timeout_seconds = 5 ``` -Do not add zmx, Remote Control, transcript ingestion, retry, or concurrency knobs -until a shipping feature needs them. +DIS-54 may add a pinned zmx cockpit block only after the required VT transaction +and redaction contract exists. Do not add raw-send, Remote Control, transcript +ingestion, retry, or general concurrency knobs. ## Fixtures and tests @@ -574,11 +686,22 @@ deterministic hook calls/events for: - permission/elicitation attention; - duplicate and late events; - process crash before frame write, after possible write, and after processing starts; -- daemon restart and generation change. +- daemon restart and generation change; +- daemon crash leaving a live detached owned generation: exact-group terminate, + indeterminate receipt preservation, quarantine on identity mismatch; +- guarded cockpit home/detail/return/reply transitions, stale-revision abort, and + atomic payload-plus-Enter ACK with no raw-input logging. Use shared provider contract tests against Codex and Claude fakes where semantics overlap. Do not bloat the existing App Server fake with Claude methods. +The capability projection test must load +`spikes/claude/fixtures/capability-policy.json` and report human coexistence as +`supported=false`, `available_now=false`, `reason=transport_blocked` while its +research status is `blocked`. Changing that policy requires one pinned live +artifact that satisfies every one-shared-history check below; separate successful +resume and attach observations cannot override the shared blocker fixture. + ### Sanitized provider fixtures Promote content-free hook shapes from `spikes/claude/fixtures/` into exercised @@ -591,24 +714,49 @@ A small temp-repo scenario, outside `just check`, uses Haiku and proves: - explicit UUID and first message; - structurally corroborated processing/completion; -- fresh-process second message; +- second message through the same persistent owner; - interrupt and resume; - duplicate request ID does not create a second provider prompt; -- process cleanup and unchanged live settings hashes. +- a second resume owner is rejected while the persistent owner lease is live; +- an ordinary attached TUI coexistence probe is expected to demonstrate the + pinned-version split-brain blocker until DIS-54 supplies a safe replacement; +- process cleanup inside the contained temp profile/settings home; do not open or + hash live global/project settings. + +The Agent View cockpit scenario remains disabled in this research run because an +isolated launch mutated the user settings file. DIS-54 may run it only after a +contained profile/settings-home mechanism makes global mutation structurally +impossible. It must +cover human zmx attach/detach, guarded quick reply to one disposable background +owner, hook/owned-activity receipts, concurrent-human revision abort, cockpit +restart without worker resume, and cleanup. Never use existing sessions or global/project settings. +The coexistence gate must not pass from separate “resume completed” and “TUI +remained alive” facts. It passes only when one shared history proves that: + +1. the already attached human observes the Dispatch turn; +2. the next human turn includes that Dispatch turn in context; +3. the next Dispatch turn includes the human turn in context; and +4. all three turns retain ordered aggregate acceptance/completion receipts. + +Until that gate passes on the pinned transport/version, capability projection +reports human coexistence as blocked and the adapter must not claim transparent +control of existing human sessions. + ## Rollout -1. Ship migration/capability/runtime skeleton behind `providers.claude.enabled = - false`. +1. Ship no enabled Claude transport until DIS-54 resolves or product explicitly + accepts exclusive headless handoff; any migration/capability/runtime skeleton + remains behind `providers.claude.enabled = false`. 2. Dogfood with opt-in temp projects and inspect only normalized events. 3. Enable explicit `new --provider claude`; keep Codex default. 4. Add provider status/doctor output: executable/version, hook health, active process count, last receipt, and cleanup drift—never auth or transcript data. 5. Expand queue/attention only after receipt/restart telemetry is stable. -6. Consider Agent View response, Remote Control, rich input, or zmx only through - separate evidence/decision issues. +6. Enable the Agent View cockpit only after its zmx and receipt gates pass; + consider Remote Control, rich input, or a worker-TUI zmx mode separately. Rollback disables new Claude launches, stops only Dispatch-owned active Claude processes, preserves provider identities/receipts, and leaves sessions resumable @@ -617,13 +765,33 @@ new provider columns. ## Ordered implementation slices -### 1. DIS-50 — Vertical Claude walking skeleton +### 1. DIS-54 — Safe human-coexistent Claude transport decision + +- preserve an already attached human client's coherent view while Dispatch sends; +- require owned-stream activity plus aggregate hook settlement for acceptance; +- require repeated Stop-cycle reduction plus terminal per-message result; +- prove interrupt/restart and explicit owner handoff without transcript reads; +- implement/prove the preferred persistent zmx-hosted Agent View cockpit using + Crew's target-safe quick-reply sequence; +- add revisioned VT snapshot, named keys, serialized conditional input ACK, + atomic payload-plus-Enter, automation lease, nonzero loss/overflow, and complete + input-log redaction to a pinned zmx build; +- keep zmx receipts transport-only and prove aggregate Claude hook settlement + plus owned provider activity without raw transcript reads. + +Current state: preferred design identified but blocked on implementation/proof. +Crew proves guarded Agent View quick reply through cmux; Dispatch has not proven +the route through zmx or the required receipt stream. Claude 2.1.210 ordinary +resume splits history, and zmx 0.6.0 fails transaction, receipt, and privacy +requirements. DIS-54 blocks enablement and DIS-50 acceptance. + +### 2. DIS-50 — Vertical Claude walking skeleton One PR, not an abstraction-only precursor: - lane identity migration and provider-qualified routing; - fixed provider manager with existing Codex adapter; -- Claude runtime launch/resume process owner; +- exclusive persistent stream runtime plus post-exit resume owner; - generated per-process settings and content-minimizing hook with preflight; - persisted random request ID and monotonic processing/completion receipt; - `new`, `send`, `stop`, watch/status, restart recovery, second message; @@ -633,37 +801,38 @@ One PR, not an abstraction-only precursor: Acceptance: a daemon restart between completed turns preserves the UUID; a later send completes; duplicate client request ID creates no second Claude prompt; SIGINT proves transport interruption; after processing starts, provider completion -remains unknown until later evidence or explicit audited abandonment. No raw -content is retained. +remains unknown until later evidence or explicit audited abandonment. A human +attach follows the DIS-54 transport or an explicitly accepted exclusive handoff; +Dispatch never resumes behind an attached human. No raw content is retained. -### 2. DIS-51 — Receipt and reducer hardening +### 3. DIS-51 — Receipt and reducer hardening - attempt table/generation fencing; - hook health, StopFailure, late/out-of-order events; - provider-scoped dedupe and receipt monotonicity migration; - bounded provider event fixtures and privacy tests. -### 3. DIS-52 — Queue and attention +### 4. DIS-52 — Queue and attention - provider-confirmed readiness; - single in-flight claim and restart reset; - permission/notification/elicitation inbox mapping; - uncertain-attempt operator recovery. -### 4. DIS-53 — Agent View metadata discovery +### 5. DIS-53 — Agent View metadata discovery - provider-qualified unmanaged discovery metadata only; - Agent View metadata adapter if still supported; - authority policy and effective-worktree metadata; - stale identity/cwd recovery. -### 5. DIS-49 — Provider-selection shorthand surfaces +### 6. DIS-49 — Provider-selection shorthand surfaces - derived `--claude` / `--codex` CLI shorthands over DIS-50's canonical enum; - config/presets/schema/help/completion/skill docs; - parity and conflict tests. -### 6. Optional capabilities +### 7. Optional capabilities Separate decisions/issues for structured output, history retention, programmatic attention response, rename, rich input, Remote Control, and any future zmx build. @@ -671,9 +840,11 @@ Unsupported operations remain explicit until their issue closes with evidence. ## Risks and non-goals -Highest risks are wrong-provider routing, stale or same-UID-spoofed hooks, duplicate turns, -receipt regression, raw-content retention, and accidental signalling of a user -process. The first slice must test each boundary. +Highest risks are split-brain ownership, wrong-provider routing, stale or +same-UID-spoofed hooks, duplicate turns, receipt regression, raw-content +retention, and accidental signalling of a user process. The first slice must +test each boundary and cannot be enabled until DIS-54 is resolved or exclusive +handoff is explicitly accepted. Non-goals: generalized provider plugins, production zmx, Remote Control server, mesh/SSH transport, global Claude settings installation, transcript indexing, diff --git a/docs/research/claude-control-plane-verification.md b/docs/research/claude-control-plane-verification.md index 8a9b2d0..7a980ad 100644 --- a/docs/research/claude-control-plane-verification.md +++ b/docs/research/claude-control-plane-verification.md @@ -7,12 +7,32 @@ Claude Agent SDK ## Verdict -Dispatch can support durable, multi-turn Claude sessions without a persistent -PTY. The smallest trustworthy transport is one owned, serialized -`claude --resume --print` subprocess per message. Dispatch chooses the -UUID, supplies per-invocation settings, receives structured stream output, and -combines its content-minimized hook with the owned CLI stream. No individual -hook, process exit, or output text is acceptance or completion authority. +Dispatch can support durable, multi-turn **headless** Claude sessions. For +human-coexistent control, Crew supplies a concrete preferred design: keep the +Agent View background owner intact, host one persistent `claude agents` cockpit +in zmx, and drive Claude's own guarded quick-reply UI. zmx is the terminal/VT +substrate, never the Claude receipt protocol. + +That route is not yet verified end to end for Dispatch. Installed zmx 0.6.0 lacks +revisioned VT snapshots, named-key input, acknowledged/serialized conditional +writes, and input-log redaction. Agent View also has not exposed aggregate +sibling-hook settlement plus owned provider activity without raw transcript +access. DIS-54 owns those blockers and the pinned live proof. + +Fresh `claude --resume --print` can +complete while an ordinary TUI remains attached, yet the two processes continue +from different in-memory histories. The TUI did not display or inherit the +external turn, and a later fresh resume saw the TUI branch but not the externally +completed turn. This is split-brain continuity, not a shared session transport. + +The recommended fallback headless transport is one Dispatch-owned persistent +print process using stream-JSON input/output, with exclusive ownership and the +same aggregate hook receipts. It completed multiple serialized turns on one +process. Concurrent external resume remained unsafe and invisible to the owner, +so the runtime must reject or fence every second owner. A human handoff requires +the Dispatch owner to exit before the TUI starts and an explicit hand-back after +the TUI exits. It must not displace the Agent View cockpit candidate merely +because it is simpler. The installed CLI proved this sequence: @@ -20,22 +40,30 @@ The installed CLI proved this sequence: 2. observe `UserPromptSubmit` with the same session UUID and a provider-generated `prompt_id`, then observe all prompt hooks settle and assistant/tool activity; 3. observe one or more `Stop` cycles with the same `prompt_id`, settling the final - hook set before terminal result success and clean process exit; -4. start a fresh process with `--resume ` and complete another message; + hook set before terminal per-message result success; require clean exit when + the owner process terminates; +4. send a second message through the same persistent stream owner and complete + it; after owner loss, start one fresh `--resume ` owner; 5. interrupt an active owned process with SIGINT (exit 130, no `Stop`), then resume the same UUID successfully; 6. correlate concurrent completions by `prompt_id`, while also proving that concurrent order and duplicate suppression are not provider guarantees. Agent View separately proved durable supervisor ownership, attention metadata, -human attach/reply, detach, stop, respawn, and cleanup. Its shell commands do -not expose a non-interactive reply primitive, so it is a supervision surface, -not Dispatch's first message transport. - -zmx 0.6.0 can persist a PTY, but its raw send has no acknowledgement and can +human attach/reply, detach, stop, respawn, and cleanup. A live background owner +rejects fresh `--resume` with exit 1 even when idle and when human-attached; after +the owner is stopped, resume succeeds. Its shell commands do not expose a +non-interactive reply primitive, but its cockpit UI does expose guarded quick +reply. Crew's current implementation normalizes to home, resolves a concrete row, +opens detail and verifies title, returns and re-verifies selection, opens `❯ reply` +with a literal-space text send, then types/submits. This is the candidate Dispatch +send path once zmx and receipt gates are satisfied. + +zmx 0.6.0 can persist the cockpit PTY, but its raw send has no acknowledgement and can return zero after a transport failure. Tagged source also shows that it always -logs PTY input bytes in recoverable hexadecimal. It is excluded from the first -implementation slice. +logs PTY input bytes in recoverable hexadecimal. It is excluded as shipped, not +rejected as an architecture substrate: DIS-54 may extend/pin it with the required +VT, transaction, and redaction contract. ## Evidence rules @@ -50,6 +78,13 @@ implementation slice. transcript paths, cwd, tool input/output, and raw transcripts were dropped. - Exit status, stream replay, zmx status, and terminal scrollback are never treated alone as Claude acceptance or completion receipts. +- A disposable Agent View launch with explicit per-session settings, empty + setting sources, and an empty strict MCP config nevertheless changed the + already captured mtime/size/digest metadata of `~/.claude/settings.json` at + launch time. Its contents were not inspected or retained, and the file was not + restored or edited by the research. This product-side global mutation freezes + further Agent View launch probes in this run and is an additional integration + risk. ## Source and version ledger @@ -63,6 +98,7 @@ implementation slice. | [Remote Control](https://code.claude.com/docs/en/remote-control) | 2026-07-15 / research preview | outbound Anthropic relay, local execution, multi-device input, reconnection, product constraints | documented | | [zmx docs](https://zmx.sh/) and [tagged source](https://github.com/neurosnap/zmx/tree/v0.6.0) | 0.6.0 | PTY ownership, raw send, no ACK, buffer/drop behavior, modes, input logging | primary source + fake target | | Installed help and probes in `spikes/claude/` | Claude Code 2.1.210 / zmx 0.6.0 | actual flags, event shapes, identity, lifecycle, interrupt, concurrency, failure, cleanup | observed | +| Crew `sendClaudeAgentsMessage` and operating lessons | local worktree at `4a24fdb`; committed live quick-reply lesson plus current target-identity hardening | home/row/detail/return/reply guards; literal-space behavior; UI automation is not receipt | implementation + dogfood evidence; Dispatch/zmx proof pending | The packet's old `/docs/en/cli-reference` URL redirects; the current canonical CLI page is `/docs/en/cli-usage`. @@ -78,9 +114,10 @@ responses, including exit/outcome, while the Dispatch hook independently sends content-minimized events to the daemon. The session UUID is available in the init/result stream and in every captured -hook. `--session-id` chooses it for a new session. `--resume ` adds another -turn from a fresh process. `--fork-session` creates a different UUID with copied -history. +hook. `--session-id` chooses it for a new session. `--resume ` can create a +new owner after prior-owner exit; using it beside a live ordinary or stream owner +is unsafe despite apparent success. `--fork-session` creates a different UUID +with copied history. ### Agent View @@ -109,6 +146,49 @@ management is limited to list, attach, logs, stop/kill, respawn, and rm. `logs` is diagnostic output. `rm` removes the Agent View entry/worktree but deliberately leaves the local conversation resumable; it is not archive or transcript delete. +### Ownership and coexistence + +Disposable probes covered all supported ownership combinations without reading +existing sessions: + +- **Ordinary TUI plus fresh resume:** the external process completed with normal + receipts while the TUI stayed attached, but no external output appeared in the + TUI. The next TUI turn reported that the external marker was absent. After the + TUI exited, a fresh resume saw the TUI turns and still did not see the external + marker. One earlier run also produced `StopFailure`/context-limit failure on the + first post-external TUI turn. Process liveness therefore does not imply coherent + shared history. +- **Agent View background plus resume:** the live background pid remained the + owner after its turn reached `done/idle`; fresh resume failed with exit 1 and a + documented “currently running as a background agent” error. The same rejection + occurred while a human TUI was attached. Human input continued normally, and + resume succeeded only after explicit Agent View stop. +- **Persistent stream owner plus resume:** two messages completed serially through + one stream owner. A concurrent fresh resume also returned success, but the + owner's next turn could not see the external marker. Persistent ownership fixes + per-turn startup and preserves headless continuity only when exclusive. + +Agent View quick reply is a real UI primitive into the existing background owner. +Crew proves the guarded navigation shape and the critical literal-space behavior, +while explicitly warning that UI typing is not delivery proof. Dispatch should +host this cockpit in zmx so a human and automation observe one Agent View owner, +then use Claude hooks/owned evidence—not zmx status or scrollback—for receipts. + +The installed pieces are still insufficient. zmx 0.6.0 provides no revisioned +snapshot, conditional atomic input transaction, named keys, send ACK, or input-log +redaction. Concurrent human keystrokes can invalidate selection between screen +reads; a safe provider must abort on revision change and atomically submit payload +plus Enter under a short UI transaction lease. Even after a PTY write ACK, +acceptance remains blocked until aggregate prompt-hook settlement and owned +provider activity can be corroborated without reading raw transcripts. + +The regression criterion is one coherent history, not two live processes. An +acceptable future transport must prove that the attached human observes the +Dispatch turn, the next human turn contains it in context, and the next Dispatch +turn contains that human turn, all with ordered aggregate receipts. The checked-in +coexistence fixture encodes the current negative outcomes so liveness cannot be +mistaken for continuity. + ### Hooks and receipt joins The observed minimum fields were: @@ -127,13 +207,17 @@ The observed minimum fields were: `UserPromptSubmit` is submission evidence, not acceptance. A sibling prompt hook can exit 2 after the Dispatch observer succeeds; the CLI can still emit a success result without assistant activity or `Stop`. Processing is confirmed only after -all prompt hooks settle successfully and the owned stream begins assistant/tool -activity for the correlated prompt. +every sibling prompt hook reaches terminal settlement, none blocks, and the owned +stream begins assistant/tool activity for the correlated prompt. Terminal +nonblocking exit-1/cancelled outcomes degrade hook health but do not erase that +owned processing evidence; missing/nonterminal settlement leaves acceptance +unknown. `Stop` joins to the same `prompt_id`, but is repeatable: a sibling Stop hook can continue the turn, producing later assistant activity and another Stop cycle. Completion requires the final Stop hook set to settle without continuation, -followed by terminal result success and clean owned-process exit. It does not fire +followed by terminal per-message result success. Clean process exit is required +when an owner generation terminates, not for each message. `Stop` does not fire on user interrupt and does not prove background tasks or session crons quiescent. Per-invocation settings composed with existing settings: stream output showed @@ -168,9 +252,10 @@ read the logs but do not satisfy a no-raw-prompt-retention boundary. ```mermaid stateDiagram-v2 [*] --> Known: choose UUID and persist lane - Known --> Starting: spawn claude --session-id/--resume - Starting --> FrameNotWritten: preflight failed before stdin - Starting --> FrameMaybeWritten: stdin write began + Known --> StartingOwner: spawn claude --session-id/--resume only with exclusive ownership + StartingOwner --> OwnerReady: SessionStart preflight settles + StartingOwner --> FrameNotWritten: preflight failed before stdin + OwnerReady --> FrameMaybeWritten: stdin frame write began FrameMaybeWritten --> SubmittedObserved: Dispatch UserPromptSubmit observed SubmittedObserved --> ProcessingStarted: hooks settle without block and model/tool activity begins FrameMaybeWritten --> AcceptanceUnknown: transport or hook settlement lost @@ -179,33 +264,36 @@ stateDiagram-v2 NeedsAttention --> Running: supported human/provider response Running --> StopObserved: Stop with matching prompt_id StopObserved --> Running: settled sibling hook continues turn - StopObserved --> Completed: final hooks settle, result succeeds, process exits cleanly + StopObserved --> Completed: final hooks settle and per-message result succeeds Running --> CompletionUnknown: owned process lost or interrupted after processing began Running --> Failed: StopFailure or process failure AcceptanceUnknown --> Failed: explicit operator reconciliation - Completed --> Known: process exited; session UUID remains resumable + Completed --> OwnerReady: owner remains healthy for next serialized frame + OwnerReady --> Known: owner exits cleanly; session UUID remains resumable Failed --> Known: explicit recovery decision ``` -Only one `Starting` through terminal attempt may exist per session. A process -generation and attempt ID prevent late hooks from a dead process from changing a -new attempt. +Only one owner and one nonterminal message attempt may exist per session. A +process generation and attempt ID prevent late hooks from a dead process from +changing a new attempt. An idle owner still owns the session. ## Capability matrix Every row is resolved. `Verified` may be a direct Claude primitive or a safe Dispatch composition; `product-decision` means the primitive exists but the cross-provider product semantics must be chosen; `unsupported` means the first -adapter must return a typed capability error. +adapter must return a typed capability error; `blocked` means the required +capability has no acceptable primitive under the pinned versions. | Capability | Status | Primitive / Dispatch composition | Acceptance and completion | Failure / recovery | Confidence | | --- | --- | --- | --- | --- | --- | | Durable identity | verified | caller-chosen UUID; Agent View also has separate short ID | UUID matches stream and hooks | resolve UUID in project/worktree scope; never route by mutable name | high, observed 2.1.210 | -| New | verified | persist UUID, preflight hook channel, spawn `--session-id UUID --print`; optional `--bg` is human-supervised mode | prompt hook settlement + owned activity; final settled Stop cycle + result/exit | retry only when proven no frame write began; otherwise operator reconciliation | high | -| Owned resume-for-send | verified | Dispatch starts `--resume UUID`; never managed `--continue` | `SessionStart(source=resume)` then ordinary aggregate receipts | stale/wrong-cwd lookup is typed not-found | high | +| New | verified | headless qualifier: persist UUID, spawn exclusive persistent `--session-id UUID --print` stream owner, settle preflight; optional `--bg` is separate human-supervised mode | prompt hook settlement + owned activity; final settled Stop cycle + terminal per-message result | retry only when proven no frame write began; otherwise operator reconciliation | high | +| Post-exit resume owner | verified | Dispatch starts one `--resume UUID` stream owner only after proven prior-owner exit; never managed `--continue` | `SessionStart(source=resume)` then ordinary aggregate receipts | stale/wrong-cwd/owner-conflict are typed failures | high | | Human Agent View attach | verified | human `claude attach SHORT_ID` to a known disposable Agent View entry | UI and ordinary hooks; not a Dispatch send transport | human supervision only; no shell reply RPC | high | +| Preserve attached human while Dispatch sends | blocked | preferred candidate: zmx-hosted Agent View cockpit + target-safe quick reply; same background owner remains authoritative | not yet sufficient: guarded PTY write needs aggregate hook settlement + owned provider activity | DIS-54 adds revisioned snapshot/atomic input/redaction and pinned live proof; abort on human revision race | medium-high design confidence; Dispatch proof blocked | | Dispatch attach of an unmanaged ordinary session | unsupported | no content-free metadata validation primitive was proven | none | do not register writable authority from UUID alone | high | -| Send | verified | serialized fresh `--resume UUID --print` process per message | processing after aggregate prompt settlement + activity; completion after final Stop settlement/result/exit | any possible frame write plus loss is indeterminate; never auto-retry | high | +| Headless send | verified | serialized messages through one exclusive persistent stream-JSON owner; fresh `--resume UUID` creates the next owner only after proven prior-owner exit | processing after aggregate prompt settlement + activity; completion after final Stop settlement/result; process exit is required when the owner terminates | any possible frame write plus loss is indeterminate; never auto-retry; reject second owners | high | | Steer active turn | unsupported | no documented print-process steer RPC; TUI input semantics are not equivalent | none | expose unsupported, do not queue under a steer label | high | | Durable queue/readiness | product-decision | Dispatch queue + one-writer lease; drain only after terminal attempt, empty background work, and healthy hooks | next aggregate receipt sequence | frame/acceptance/completion uncertainty blocks drain until explicit operator resolution | high evidence, policy open | | Interject | product-decision | SIGINT verified owned process group, await exit, then start a new turn | exit proves transport interruption; new prompt needs normal receipts | not atomic; provider completion may remain unknown | high evidence, semantics open | @@ -220,7 +308,7 @@ adapter must return a typed capability error. | User input/elicitation | product-decision | attention hooks + Agent View/attach human response | shared `prompt_id`; later `Stop` | no shell reply RPC; first slice surfaces attention but does not synthesize answers | high | | Structured output | verified | print mode `--json-schema` and JSON/stream result | validated structured result + normal Stop | schema failure is typed provider failure; not an interactive-lane default | medium-high, documented/help | | Rich input/files/images | product-decision | `--file` addresses provider file resources; Agent View supports human image paste | normal prompt receipt | local file/image stream contract not established; first slice text only | medium | -| Process restart/recovery | verified | fresh `--resume`; Agent View stop/respawn retained full UUID | `SessionStart(resume)` and later receipts | partition recovery by provider; do not call Codex resume for Claude | high | +| Process restart/recovery | verified | after proven owner exit, fresh `--resume`; Agent View stop/respawn retained full UUID | `SessionStart(resume)` and later receipts | never resume while an ordinary/stream owner may remain; require explicit ownership reconciliation | high | | Duplicate/concurrent send | verified | provider processes both and generates distinct prompt IDs | each turn has independent aggregate receipt cycles | enforce one writer; duplicate request ID returns stored receipt; ambiguous attempt not retried | high | | Remote/mesh compatibility | product-decision | Remote Control is Anthropic relay; Dispatch mesh remains owning-daemon execution | provider hooks at owner | no private endpoint or shared remote process; future explicit config/policy | high | @@ -230,19 +318,37 @@ adapter must return a typed capability error. | --- | --- | --- | --- | | Preflight fails before stdin | no frame write | Claude runtime | fail before submission; bounded retry with same request ID is safe | | Transport loss after write begins | frame may have reached Claude; no conclusive receipt | Claude runtime + operator | mark acceptance unknown; block queue; operator can wait, inspect content-free runtime facts, or explicitly abandon ambiguity before a new send | -| Process dies after processing starts | processing evidence but no final settled Stop/result/exit | Claude runtime + operator | mark completion unknown; never resend automatically; explicit abandonment releases queue but preserves unknown provider fact | +| Process dies after processing starts | processing evidence but no final settled Stop/per-message result | Claude runtime + operator | mark completion unknown; never resend automatically; explicit abandonment releases queue but preserves unknown provider fact | | SIGINT during active turn | exit 130, no new `Stop`; same UUID resumable | Claude runtime | record transport interruption; after processing starts retain completion unknown until operator abandons or later evidence resolves it | | Prompt hook block after observer | Dispatch hook succeeds, sibling exits 2, no assistant/Stop, result may say success | hook reducer | submission only; do not mark processing | -| Stop hook continuation | repeated Stop cycles with same prompt ID | hook reducer | retain each occurrence; only final settled cycle plus result/exit completes | -| Hook exit 1 | prompt may proceed | hook ingest | mark hook unhealthy/acceptance unknown unless complete aggregate evidence exists | -| Hook timeout | hook response cancelled/exit 1; prompt proceeds | hook ingest | same as exit 1; never interpret as rejection | +| Stop hook continuation | repeated Stop cycles with same prompt ID | hook reducer | retain each occurrence; only final settled cycle plus terminal per-message result completes | +| Hook exit 1 | prompt may proceed | hook ingest | wait for every sibling's terminal settlement; if none blocks and owned activity follows, mark processing with degraded hook health; otherwise acceptance remains unknown | +| Hook timeout | hook response cancelled/exit 1; prompt proceeds | hook ingest | same terminal-settlement rule as exit 1; never interpret as rejection | | Duplicate request | two provider turns if sent twice | Dispatch | dedupe before spawn by durable Dispatch message ID | | Concurrent writers | both produced turns; ordering is not launch order | Dispatch | single-writer transaction/lease per session | +| Fresh resume while ordinary TUI attached | both processes can complete but histories diverge; external turn was absent from the TUI and later resume | Claude runtime + operator | mark ownership conflict; stop sends; explicit human handoff/reconciliation; never claim shared continuity | +| Fresh resume while stream owner lives | external process can complete but its turn is absent from the continuing owner | Dispatch supervisor | reject second owner by durable lease/process identity; after uncertainty, block until operator resolves ownership | +| Fresh resume while Agent View owner lives | exit 1, owner remains coherent and human-usable | Agent View | attach for human use or stop owner before a later resume; no Dispatch send during ownership | +| Cockpit target/detail/reply guard mismatch | Crew path fails closed before payload | cockpit reducer | renormalize home and retry only before any payload write; never fall back to raw send | +| Human changes cockpit revision | target/input state may have moved | hardened zmx transaction | abort before payload with `cockpit_changed`; reacquire snapshot and re-run every identity guard | +| Cockpit loss after possible payload write | PTY write/Enter may have reached Agent View | cockpit transport + operator | acceptance indeterminate; no automatic retry; wait for aggregate receipts or explicitly abandon | +| Cockpit restart | Agent View background owner continues independently | zmx supervisor | restart cockpit only, re-resolve full UUID/row, then re-run guards; never resume the worker | +| Cockpit hook/activity gap | UI write ACK without aggregate hook settlement and owned provider activity | receipt reducer | keep capability blocked and attempt unaccepted; transcript metadata/roster state cannot promote it | | Permission/user input | hooks + Agent View waiting metadata | Dispatch attention reducer + human | durable inbox item; first slice requires attach/Agent View response | | Daemon restart | Claude transcript persists; owned process may be gone | Dispatch supervisor | fence old generation, inspect pid, resume only on next explicit send | | zmx loss | send may error text and still exit zero | zmx adapter | excluded; if ever enabled, receipts must come only from Claude hooks | | Stale session/cwd | resume lookup is project/worktree scoped | selector/runtime | store canonical launch/effective cwd and return typed stale/not-found | +## Transport decision table + +| Candidate | Continuity | Delivery correlation | Human coexistence | Interrupt / restart | Privacy / logging | Implementation cost | Verdict | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Exclusive persistent stream-JSON owner | verified across multiple serialized turns while one owner lives | strongest: owned frames, stream activity, aggregate hooks, prompt IDs | no supported attached TUI; concurrent resume splits history | owned process group; after proven exit, resume UUID as a new owner | no extra PTY logger; retain only normalized events | medium | recommended headless base, but does not satisfy seamless coexistence | +| Fresh `--resume --print` per turn | verified only with no other live owner | strong per-process stream + aggregate hooks | failed: ordinary TUI/stream owner diverged; Agent View rejected it | simple process lifecycle and restart | no extra PTY logger | low | rejected as the default transport | +| zmx-hosted Agent View cockpit | preserves the Agent View background owner; Crew proves guarded quick-reply UI route | zmx write ACK is transport-only; Claude aggregate hooks + owned activity still required | preferred: human attaches/detaches from same cockpit; revision race must abort | Agent View supervisor owns workers; zmx cockpit restarts without resuming workers | blocked as shipped: 0.6.0 logs input and lacks conditional atomic writes | high | recommended DIS-54 direction; not enable-ready | +| zmx-owned worker TUI | one PTY could serialize human and Dispatch input in principle | Claude hooks could confirm after raw injection, but zmx itself has no ACK/order guarantee | possible but duplicates Agent View supervision and attention UI | raw Ctrl-C/loss unconfirmed; persistent process | blocked: 0.6.0 logs raw PTY input and can drop queued bytes | medium-high | fallback investigation, not preferred | +| Remote Control / Agent View controls | provider-owned multi-device human continuity | no documented local programmable send RPC | human-facing only | provider reconnection/supervisor semantics | Anthropic relay/product policy | high/product | deferred product decision | + ## Security and privacy findings - Never retain raw hook input by default. Validate size/type, extract bounded @@ -250,6 +356,10 @@ adapter must return a typed capability error. - A hook command is executable configuration. Generate an owner-only settings file in Dispatch runtime state, use argv-safe process creation, and never interpolate user text into a shell command. +- `--settings`, `--setting-sources ''`, and strict empty MCP configuration did + not prevent Agent View from mutating the user settings file in the observed + build. Treat Agent View launch as globally mutating until the product provides + a proven isolation contract; do not use it in automated provider tests. - Give each process generation a random nonce. The Dispatch hook returns it in structured hook output so the owned stream can distinguish that helper and reject stale generations. The nonce is a misrouting fence, not authentication diff --git a/spikes/claude/README.md b/spikes/claude/README.md index 73aa600..bf30dbd 100644 --- a/spikes/claude/README.md +++ b/spikes/claude/README.md @@ -33,16 +33,51 @@ uv run python spikes/claude/assert_probe.py negative-fixtures \ spikes/claude/fixtures/negative uv run python spikes/claude/assert_probe.py receipt \ spikes/claude/fixtures/tool-only-processing.jsonl +uv run python spikes/claude/assert_probe.py message-receipt \ + spikes/claude/fixtures/persistent-owner-completion.jsonl +uv run python spikes/claude/assert_probe.py coexistence-fixture \ + spikes/claude/fixtures/coexistence-outcomes.jsonl +uv run python spikes/claude/assert_probe.py cockpit-plan-fixture \ + spikes/claude/fixtures/agent-view-cockpit-plan.jsonl jq -cf spikes/claude/sanitize_stream.jq \ spikes/claude/fixtures/whitespace-block-raw.jsonl | jq -e '.blocking_decision == true and (has("stdout") | not)' ``` +`coexistence-outcomes.jsonl` contains only synthetic markers and normalized +outcomes from disposable sessions. Its assertion deliberately requires +`shared_coherent_history=false` for ordinary TUI plus resume, Agent View plus +resume, and persistent stream owner plus resume. A future adapter must not flip +the public capability from blocked merely because both processes stayed alive or +both emitted successful results. Replace that negative gate only with a pinned +live scenario proving the attached human sees the Dispatch turn, the next human +turn contains it, and the next Dispatch turn contains the human turn, all with +ordered aggregate receipts. + +`agent-view-cockpit-plan.jsonl` is a design fixture derived from Crew's guarded +Agent View quick-reply route, not a new live Dispatch proof. It requires exact +roster/row identity, home/detail/return/reply viewport guards, revision-conditional +serialized input, one atomic payload-plus-Enter batch, and abort on concurrent +human revision change. It also deliberately retains the receipt blocker: +Agent View has not yet exposed aggregate sibling-hook settlement plus owned +provider activity to Dispatch without raw transcript access. `just check` runs +these fixtures through `tests/fixtures/test_claude_research.py`. + +`capability-policy.json` is the machine-readable adapter gate. Repository tests +keep human coexistence `blocked` until DIS-54 replaces the negative evidence with +one pinned live one-shared-history proof. The cockpit plan assertion enforces one +exclusive automation lease, revision-conditional ordered keys, an atomic +payload-plus-Enter write, content-free logs, and abort-before-payload when a +human revision invalidates the lease. + ## Safety envelope - Use Claude Code 2.1.210 and zmx 0.6.0 for these exact claims. - Use Haiku, minimal prompts, an explicit UUID, and a temporary Git repository. - Never target an existing session or existing Agent View short ID. +- Do not automate further Agent View launches on 2.1.210: an isolated launch + changed `~/.claude/settings.json` despite explicit per-session settings, empty + setting sources, and strict empty MCP configuration. - Never inspect transcripts, auth, or zmx history for real Claude prompts. - Record settings hashes/metadata without printing settings contents. - Use a unique prefix and remove every Agent View/zmx entry created. @@ -128,7 +163,8 @@ correlation only. Processing additionally requires every `UserPromptSubmit` hook response to reach a terminal non-blocking outcome and owned-stream assistant/tool activity. Completion requires the final Stop hook set to settle without continuation, -terminal result success, and clean process exit. +and terminal per-message result success. Clean process exit is a separate +requirement when an owner generation terminates. ## Hook aggregation and preflight probes diff --git a/spikes/claude/assert_probe.py b/spikes/claude/assert_probe.py index 82aa98c..4bca191 100644 --- a/spikes/claude/assert_probe.py +++ b/spikes/claude/assert_probe.py @@ -49,7 +49,7 @@ def assert_processing(events: list[Event]) -> None: ) -def assert_completed(events: list[Event]) -> None: +def assert_message_completed(events: list[Event]) -> int: assert_processing(events) activities = [event["sequence"] for event in events if is_provider_activity(event)] last_activity = max(activities) @@ -69,7 +69,11 @@ def assert_completed(events: list[Event]) -> None: and event["sequence"] > stop_settled ] assert results - result_sequence = max(event["sequence"] for event in results) + return max(event["sequence"] for event in results) + + +def assert_completed(events: list[Event]) -> None: + result_sequence = assert_message_completed(events) assert any( event.get("type") == "process_exit" and event.get("exit_code") == 0 @@ -81,6 +85,10 @@ def assert_completed(events: list[Event]) -> None: def assert_structure(mode: str, events: list[Event]) -> None: if mode == "receipt": assert_completed(events) + elif mode == "message-receipt": + result_sequence = assert_message_completed(events) + assert not any(event.get("type") == "process_exit" for event in events) + assert result_sequence == max(event["sequence"] for event in events) elif mode == "block-prompt": assert_hooks_paired(events) assert any(event.get("exit_code") == 2 for event in responses(events, "UserPromptSubmit")) @@ -167,6 +175,186 @@ def assert_negative_fixtures(directory: Path) -> None: raise AssertionError(f"negative fixture unexpectedly completed: {name}") +def assert_coexistence_fixture(events: list[Event]) -> None: + by_case: dict[str, list[Event]] = {} + for event in events: + by_case.setdefault(event["case"], []).append(event) + + ordinary = by_case["ordinary_tui"] + assert any( + event.get("event") == "shared_coherent_history" and event.get("value") is False + for event in ordinary + ) + assert any( + event.get("actor") == "external_resume" and event.get("event") == "turn_completed" + for event in ordinary + ) + assert any( + event.get("actor") == "attached_tui" + and event.get("event") == "marker_visible" + and event.get("marker") == "external" + and event.get("value") is False + for event in ordinary + ) + assert any( + event.get("actor") == "fresh_resume" + and event.get("event") == "marker_visible" + and event.get("marker") == "external" + and event.get("value") is False + for event in ordinary + ) + + agent_view = by_case["agent_view"] + assert any( + event.get("event") == "shared_coherent_history" and event.get("value") is False + for event in agent_view + ) + assert any( + event.get("actor") == "external_resume" + and event.get("event") == "resume_rejected" + and event.get("exit_code") == 1 + and event.get("owner_alive") is True + for event in agent_view + ) + assert any( + event.get("actor") == "attached_tui" + and event.get("event") == "resume_rejected" + and event.get("exit_code") == 1 + and event.get("owner_alive") is True + for event in agent_view + ) + assert any(event.get("event") == "attached_human_turn_completed" for event in agent_view) + assert any(event.get("event") == "resume_completed_after_owner_stop" for event in agent_view) + + stream_owner = by_case["persistent_stream_owner"] + assert any( + event.get("event") == "shared_coherent_history" and event.get("value") is False + for event in stream_owner + ) + assert ( + len([event for event in stream_owner if event.get("event") == "owner_turn_completed"]) >= 2 + ) + assert any( + event.get("actor") == "owner" + and event.get("event") == "marker_visible" + and event.get("marker") == "external" + and event.get("value") is False + for event in stream_owner + ) + + +def assert_cockpit_plan_fixture(events: list[Event]) -> None: + assert [event["sequence"] for event in events] == list(range(1, len(events) + 1)) + assert [event["event"] for event in events] == [ + "target_resolved", + "home_verified", + "lease_acquired", + "input_ack", + "input_ack", + "detail_identity_verified", + "input_ack", + "home_selection_reverified", + "input_ack", + "reply_prompt_verified", + "input_ack", + "lease_released", + "lease_acquired", + "concurrent_human_revision_changed", + "receipt_blocker", + ] + assert events[0].get("event") == "target_resolved" + assert events[0].get("full_session_id_verified") is True + + required_guards = ( + "home_verified", + "detail_identity_verified", + "home_selection_reverified", + "reply_prompt_verified", + ) + for guard in required_guards: + assert any(event.get("event") == guard and event.get("value") is True for event in events) + assert events[1].get("target_row_visible") is True + assert events[5].get("full_session_id_verified") is True + assert events[5].get("title_verified") is True + assert events[7].get("full_session_id_verified") is True + assert events[9].get("target_identity_retained") is True + + first_lease = events[2] + assert first_lease.get("exclusive") is True + lease_id = first_lease["lease_id"] + current_revision = first_lease["at_revision"] + writes: list[Event] = [] + for event in events[3:12]: + if snapshot_revision := event.get("snapshot_revision"): + assert snapshot_revision > current_revision + current_revision = snapshot_revision + if event.get("event") == "input_ack": + assert event.get("lease_id") == lease_id + assert event.get("expected_revision") == current_revision + assert event["result_revision"] > current_revision + current_revision = event["result_revision"] + writes.append(event) + + assert [event["input"] for event in writes] == [ + "named_key_down", + "named_key_enter", + "named_key_left", + "literal_space", + "message_submit", + ] + assert all(event.get("serialized") is True for event in writes) + assert all(event.get("raw_input_logged") is False for event in writes) + assert writes[-1].get("input") == "message_submit" + assert writes[-1].get("atomic_batch") == ["payload", "enter"] + assert events[11] == { + "sequence": 12, + "event": "lease_released", + "lease_id": lease_id, + "at_revision": current_revision, + } + + forbidden_content_keys = { + "prompt", + "text", + "payload", + "raw", + "stdout", + "stderr", + "log", + "input_bytes", + "transcript", + "message", + } + + def assert_content_free(value: Any) -> None: + if isinstance(value, dict): + assert not forbidden_content_keys.intersection(value) + for nested in value.values(): + assert_content_free(nested) + elif isinstance(value, list): + for nested in value: + assert_content_free(nested) + + assert_content_free(events) + + second_lease = events[12] + human_race = events[13] + assert second_lease.get("exclusive") is True + assert human_race.get("lease_id") == second_lease.get("lease_id") + assert human_race.get("expected_revision") == second_lease.get("at_revision") + assert human_race["actual_revision"] > human_race["expected_revision"] + assert human_race.get("lease_invalidated") is True + assert human_race.get("action") == "abort_before_payload" + assert not any( + event.get("event") == "input_ack" and event.get("lease_id") == second_lease.get("lease_id") + for event in events + ) + blocker = next(event for event in events if event.get("event") == "receipt_blocker") + assert blocker.get("aggregate_hook_settlement") == "unproven" + assert blocker.get("owned_provider_activity") == "unproven" + assert blocker.get("capability") == "blocked" + + def main() -> None: if len(sys.argv) < 3: raise SystemExit("usage: assert_probe.py MODE PATH [PATH ...]") @@ -178,6 +366,10 @@ def main() -> None: assert_aggregate_fixture(load(paths[0])) elif mode == "negative-fixtures": assert_negative_fixtures(paths[0]) + elif mode == "coexistence-fixture": + assert_coexistence_fixture(load(paths[0])) + elif mode == "cockpit-plan-fixture": + assert_cockpit_plan_fixture(load(paths[0])) else: assert len(paths) == 1 assert_structure(mode, load(paths[0])) diff --git a/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl b/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl new file mode 100644 index 0000000..348a925 --- /dev/null +++ b/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl @@ -0,0 +1,15 @@ +{"sequence":1,"event":"target_resolved","full_session_id_verified":true,"roster_source":"claude_agents_json","selector":"synthetic-short-id"} +{"sequence":2,"event":"home_verified","value":true,"snapshot_revision":10,"target_row_visible":true} +{"sequence":3,"event":"lease_acquired","lease_id":"00000000-0000-4000-8000-000000000301","at_revision":10,"exclusive":true} +{"sequence":4,"event":"input_ack","input":"named_key_down","expected_revision":10,"result_revision":11,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false} +{"sequence":5,"event":"input_ack","input":"named_key_enter","expected_revision":11,"result_revision":12,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false} +{"sequence":6,"event":"detail_identity_verified","value":true,"snapshot_revision":13,"full_session_id_verified":true,"title_verified":true} +{"sequence":7,"event":"input_ack","input":"named_key_left","expected_revision":13,"result_revision":14,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false} +{"sequence":8,"event":"home_selection_reverified","value":true,"snapshot_revision":15,"full_session_id_verified":true} +{"sequence":9,"event":"input_ack","input":"literal_space","expected_revision":15,"result_revision":16,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false} +{"sequence":10,"event":"reply_prompt_verified","value":true,"snapshot_revision":17,"input_mode":"quick_reply","target_identity_retained":true} +{"sequence":11,"event":"input_ack","input":"message_submit","expected_revision":17,"result_revision":18,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false,"atomic_batch":["payload","enter"],"payload_id":"00000000-0000-4000-8000-000000000201"} +{"sequence":12,"event":"lease_released","lease_id":"00000000-0000-4000-8000-000000000301","at_revision":18} +{"sequence":13,"event":"lease_acquired","lease_id":"00000000-0000-4000-8000-000000000302","at_revision":20,"exclusive":true} +{"sequence":14,"event":"concurrent_human_revision_changed","lease_id":"00000000-0000-4000-8000-000000000302","expected_revision":20,"actual_revision":21,"lease_invalidated":true,"action":"abort_before_payload"} +{"sequence":15,"event":"receipt_blocker","aggregate_hook_settlement":"unproven","owned_provider_activity":"unproven","capability":"blocked"} diff --git a/spikes/claude/fixtures/capability-policy.json b/spikes/claude/fixtures/capability-policy.json new file mode 100644 index 0000000..81635a0 --- /dev/null +++ b/spikes/claude/fixtures/capability-policy.json @@ -0,0 +1,12 @@ +{ + "human_coexistence": { + "status": "blocked", + "supported": false, + "available_now": false, + "reason": "transport_blocked", + "negative_evidence": "coexistence-outcomes.jsonl", + "candidate": "agent-view-cockpit", + "candidate_plan": "agent-view-cockpit-plan.jsonl", + "positive_gate": "pinned-live-one-shared-history" + } +} diff --git a/spikes/claude/fixtures/coexistence-outcomes.jsonl b/spikes/claude/fixtures/coexistence-outcomes.jsonl new file mode 100644 index 0000000..3626838 --- /dev/null +++ b/spikes/claude/fixtures/coexistence-outcomes.jsonl @@ -0,0 +1,17 @@ +{"case":"ordinary_tui","sequence":1,"actor":"attached_tui","event":"turn_completed","marker":"initial"} +{"case":"ordinary_tui","sequence":2,"actor":"external_resume","event":"turn_completed","marker":"external","exit_code":0} +{"case":"ordinary_tui","sequence":3,"actor":"attached_tui","event":"marker_visible","marker":"external","value":false} +{"case":"ordinary_tui","sequence":4,"actor":"attached_tui","event":"turn_completed","marker":"human_after_external"} +{"case":"ordinary_tui","sequence":5,"actor":"fresh_resume","event":"marker_visible","marker":"external","value":false} +{"case":"ordinary_tui","sequence":6,"actor":"reducer","event":"shared_coherent_history","value":false,"reason":"divergent_histories"} +{"case":"agent_view","sequence":1,"actor":"background_owner","event":"turn_completed","state":"done","status":"idle","owner_alive":true} +{"case":"agent_view","sequence":2,"actor":"external_resume","event":"resume_rejected","exit_code":1,"owner_alive":true} +{"case":"agent_view","sequence":3,"actor":"attached_tui","event":"resume_rejected","exit_code":1,"owner_alive":true} +{"case":"agent_view","sequence":4,"actor":"attached_tui","event":"attached_human_turn_completed"} +{"case":"agent_view","sequence":5,"actor":"external_resume","event":"resume_completed_after_owner_stop","exit_code":0} +{"case":"agent_view","sequence":6,"actor":"reducer","event":"shared_coherent_history","value":false,"reason":"no_programmatic_send_while_owner_alive"} +{"case":"persistent_stream_owner","sequence":1,"actor":"owner","event":"owner_turn_completed","marker":"first"} +{"case":"persistent_stream_owner","sequence":2,"actor":"owner","event":"owner_turn_completed","marker":"second"} +{"case":"persistent_stream_owner","sequence":3,"actor":"external_resume","event":"turn_completed","marker":"external","exit_code":0} +{"case":"persistent_stream_owner","sequence":4,"actor":"owner","event":"marker_visible","marker":"external","value":false} +{"case":"persistent_stream_owner","sequence":5,"actor":"reducer","event":"shared_coherent_history","value":false,"reason":"external_resume_diverged"} diff --git a/spikes/claude/fixtures/persistent-owner-completion.jsonl b/spikes/claude/fixtures/persistent-owner-completion.jsonl new file mode 100644 index 0000000..7158ea7 --- /dev/null +++ b/spikes/claude/fixtures/persistent-owner-completion.jsonl @@ -0,0 +1,6 @@ +{"sequence":1,"type":"system","subtype":"hook_started","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000251"} +{"sequence":2,"type":"system","subtype":"hook_response","hook_event":"UserPromptSubmit","hook_id":"00000000-0000-4000-8000-000000000251","exit_code":0,"outcome":"success"} +{"sequence":3,"type":"assistant"} +{"sequence":4,"type":"system","subtype":"hook_started","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000252"} +{"sequence":5,"type":"system","subtype":"hook_response","hook_event":"Stop","hook_id":"00000000-0000-4000-8000-000000000252","exit_code":0,"outcome":"success"} +{"sequence":6,"type":"result","subtype":"success"} diff --git a/tests/fixtures/test_claude_research.py b/tests/fixtures/test_claude_research.py new file mode 100644 index 0000000..cbd2982 --- /dev/null +++ b/tests/fixtures/test_claude_research.py @@ -0,0 +1,48 @@ +"""Keep Claude research capability blockers executable in the repository gate.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def run_probe(mode: str, fixture: str) -> None: + root = Path(__file__).parents[2] + subprocess.run( + [ + sys.executable, + str(root / "spikes/claude/assert_probe.py"), + mode, + str(root / f"spikes/claude/fixtures/{fixture}"), + ], + check=True, + cwd=root, + ) + + +def test_human_coexistence_capability_policy_remains_blocked() -> None: + root = Path(__file__).parents[2] + policy = json.loads((root / "spikes/claude/fixtures/capability-policy.json").read_text())[ + "human_coexistence" + ] + assert policy == { + "status": "blocked", + "supported": False, + "available_now": False, + "reason": "transport_blocked", + "negative_evidence": "coexistence-outcomes.jsonl", + "candidate": "agent-view-cockpit", + "candidate_plan": "agent-view-cockpit-plan.jsonl", + "positive_gate": "pinned-live-one-shared-history", + } + run_probe("coexistence-fixture", "coexistence-outcomes.jsonl") + + +def test_agent_view_cockpit_plan_retains_receipt_blocker() -> None: + run_probe("cockpit-plan-fixture", "agent-view-cockpit-plan.jsonl") + + +def test_persistent_owner_message_completes_without_process_exit() -> None: + run_probe("message-receipt", "persistent-owner-completion.jsonl") From ea126d8d6e57312a43267f29ce59c529951b6b76 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:42:53 -0400 Subject: [PATCH 5/9] test: verify Claude preflight nonce --- .../RETRO.md | 6 ++--- spikes/claude/README.md | 4 +++- .../claude/fixtures/preflight-nonce-raw.jsonl | 2 ++ spikes/claude/sanitize_stream.jq | 9 +++++-- tests/fixtures/test_claude_research.py | 24 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 spikes/claude/fixtures/preflight-nonce-raw.jsonl diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index 7fdb9a8..414f035 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -247,9 +247,9 @@ No row remains pending or unknown. Full semantics and citations are in | repository gate attempt 1 | `just check` | 691 passed, 1 timing failure | focused supervisor test immediately passed; unrelated tracked code unchanged | | repository gate attempt 2 | `just check` | 692 tests passed; package-content step raced | concurrent review gate rebuilt `dist`; standalone package check passed | | pre-correction repository gate | `just check` | full-stack reviewer passed | 692 tests plus build/package; superseded by post-correction gate | -| post-correction focused fixtures | `uv run pytest -q tests/fixtures/test_claude_research.py` | passed | 3 tests: blocker policy/negative evidence, guarded cockpit plan, persistent-owner completion without process exit | +| post-correction focused fixtures | `uv run pytest -q tests/fixtures/test_claude_research.py` | passed | 4 tests: blocker policy/negative evidence, guarded cockpit plan, persistent-owner completion without process exit, exact current preflight nonce | | post-correction packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; all reports 5/5 clean with zero P0-P2 | -| post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 695 passed/17 deselected, wheel/sdist and contents | +| post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 696 passed/17 deselected, wheel/sdist and contents after hosted preflight-nonce fix | | hosted CI and PR threads | PR #92 | pending | final external closure gate after push | ## Prompt / Goal Alignment @@ -285,7 +285,7 @@ No row remains pending or unknown. Full semantics and citations are in and full-stack round 4 are each 5/5 clean with zero P0-P3. - Verification summary: packet, fixture, lint/type, sanitizer privacy, process-group, negative receipt checks, and post-correction local `just check` - pass with 695 tests; hosted PR CI and thread closure are pending. + pass with 696 tests; hosted PR CI and thread closure are pending. - Cleanup audit: research-created Agent View/zmx/process/temp resources removed; repository-local settings unchanged; Claude's Agent View launch mutated the user settings file despite isolation flags, and research neither read nor diff --git a/spikes/claude/README.md b/spikes/claude/README.md index bf30dbd..ebb9758 100644 --- a/spikes/claude/README.md +++ b/spikes/claude/README.md @@ -240,7 +240,9 @@ uv run python "$spike_root/assert_probe.py" preflight \ Require one successful response carrying the current nonce before writing any prompt frame. Do not persist other hook stdout: sibling SessionStart hooks may -return content. A missing nonce is safe failure-before-submission. After any +return content. The sanitizer parses stdout only in-flight and compares the +returned nonce with `DISPATCH_CLAUDE_PREFLIGHT_NONCE`; a marker alone or a stale +nonce cannot pass. A missing nonce is safe failure-before-submission. After any possible stdin write, loss is indeterminate and must never auto-retry. ## Interrupt, duplicate, and hook-failure probes diff --git a/spikes/claude/fixtures/preflight-nonce-raw.jsonl b/spikes/claude/fixtures/preflight-nonce-raw.jsonl new file mode 100644 index 0000000..f6f0b28 --- /dev/null +++ b/spikes/claude/fixtures/preflight-nonce-raw.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"hook_response","hook_event":"SessionStart","hook_id":"00000000-0000-4000-8000-000000000351","exit_code":0,"outcome":"success","stdout":"{\"_dispatch_preflight\":{\"nonce\":\"preflight-stale\"}}"} +{"type":"system","subtype":"hook_response","hook_event":"SessionStart","hook_id":"00000000-0000-4000-8000-000000000352","exit_code":0,"outcome":"success","stdout":"{\"_dispatch_preflight\":{\"nonce\":\"preflight-current\"}}"} diff --git a/spikes/claude/sanitize_stream.jq b/spikes/claude/sanitize_stream.jq index 46bf1e5..c107ae3 100644 --- a/spikes/claude/sanitize_stream.jq +++ b/spikes/claude/sanitize_stream.jq @@ -1,5 +1,6 @@ # Reduce Claude stream JSON immediately. Never retain message/model/tool content. -if .subtype == "hook_started" or .subtype == "hook_response" then +(env.DISPATCH_CLAUDE_PREFLIGHT_NONCE // "") as $expected_preflight_nonce +| if .subtype == "hook_started" or .subtype == "hook_response" then { sequence: input_line_number, type, @@ -10,7 +11,11 @@ if .subtype == "hook_started" or .subtype == "hook_response" then hook_name, exit_code, outcome, - dispatch_preflight: ((.stdout // "") | contains("_dispatch_preflight")), + dispatch_preflight: ( + $expected_preflight_nonce != "" + and (((.stdout // "") | fromjson? // {})._dispatch_preflight.nonce // "") + == $expected_preflight_nonce + ), blocking_decision: ( ((.stdout // "") | fromjson? // {}) | .decision == "block" diff --git a/tests/fixtures/test_claude_research.py b/tests/fixtures/test_claude_research.py index cbd2982..686a813 100644 --- a/tests/fixtures/test_claude_research.py +++ b/tests/fixtures/test_claude_research.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -46,3 +47,26 @@ def test_agent_view_cockpit_plan_retains_receipt_blocker() -> None: def test_persistent_owner_message_completes_without_process_exit() -> None: run_probe("message-receipt", "persistent-owner-completion.jsonl") + + +def test_preflight_sanitizer_requires_current_nonce() -> None: + root = Path(__file__).parents[2] + env = os.environ.copy() + env["DISPATCH_CLAUDE_PREFLIGHT_NONCE"] = "preflight-current" + result = subprocess.run( + [ + "jq", + "-cf", + str(root / "spikes/claude/sanitize_stream.jq"), + str(root / "spikes/claude/fixtures/preflight-nonce-raw.jsonl"), + ], + check=True, + capture_output=True, + text=True, + env=env, + cwd=root, + ) + events = [json.loads(line) for line in result.stdout.splitlines()] + assert "dispatch_preflight" not in events[0] + assert events[1]["dispatch_preflight"] is True + assert all("stdout" not in event for event in events) From b1cfea81ea619af7a4852886773522801f6f23fe Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:46:25 -0400 Subject: [PATCH 6/9] docs: record zmx cockpit read evidence --- .../RETRO.md | 15 ++++++++++-- docs/development/claude-provider-plan.md | 8 +++++++ .../claude-control-plane-verification.md | 23 ++++++++++++------- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index 414f035..4453138 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -102,6 +102,11 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Receipt blocker remains: Agent View quick reply has not exposed all sibling prompt-hook settlements plus owned provider activity to Dispatch without raw transcript access. Transcript mtime/size and cockpit state are corroboration, not acceptance. The preferred design is therefore identified but DIS-54 stays open. - No further Agent View process was launched because of the previously observed global-settings mutation. Added a sanitized executable cockpit-plan fixture and repository-gated negative capability test instead. +2026-07-15 - Read-only existing-cockpit evidence supplied by coordinator +- The coordinator observed an existing user-owned zmx cockpit without sending input or mutating it. zmx reported one attached client with a `claude agents` child; `history --vt` rendered the alternate-screen Agent View structure and plain history produced clean parseable text. +- Independent `claude agents --json` returned exact short/full identities and states for the blocked rows represented in the cockpit. The UI additionally retained completed rows and the quick-reply footer. +- This raises confidence in the roster-identity plus guarded-VT composition. It does not prove current-screen revision, atomic/redacted writes, concurrent-human exclusion, Claude receipts, or one shared history; DIS-54 remains blocked and all mutation tests stay disposable-only. + 2026-07-15 - Aggregate receipts, transport/security review, and preflight milestone - Transport review round 1 scored 2/5 with 3 P1 and 4 P2 findings. Security/product review round 1 scored 2/5 with 4 P1 and 2 P2 findings. Reports are local scratch under `tmp/reviews/transport/round-1.json` and `tmp/reviews/security-product/round-1.json`. - Blocking sibling prompt hook: the Dispatch observer completed, a sibling `UserPromptSubmit` hook exited 2, no assistant activity or Stop occurred, yet the CLI emitted result subtype success. Therefore observer success and result success do not prove acceptance; processing requires aggregate prompt-hook settlement plus owned-stream assistant/tool activity. @@ -145,6 +150,7 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | coexist-3 | Can one persistent stream owner carry multiple turns and share with external resume? | Two framed owner messages, concurrent resume, later owner visibility check | Claude Code 2.1.210 | Multi-turn owner verified; shared ownership failed because external turn was absent from continuing owner | high | coexistence fixture + research doc | owner exited; temp removed | | cockpit-1 | Is there a target-safe path into the existing Agent View owner? | Read Crew quick-reply implementation, committed live lessons, and current identity guards | Crew commit `4a24fdb` + dirty worktree | Yes as UI composition: exact row, detail/title, return/reselect, literal-space reply, reply-prompt guard, submit | medium-high; direct Dispatch/zmx proof pending | cockpit plan fixture + provider plan | read-only; no process launch | | cockpit-2 | Can installed zmx safely implement the cockpit route? | Compare zmx 0.6.0 help/source/fake failures to Crew console needs | zmx 0.6.0 | No as shipped: lacks revisioned screen, named/conditional atomic input, reliable ACK/errors, and redaction | high | transport table + DIS-54 | no zmx mutation | +| cockpit-3 | Can zmx expose enough Agent View screen state to implement guarded reads? | Coordinator-supplied read-only `list`, `history --vt`, plain history, process relation, and independent Agent View JSON roster from an existing user-owned cockpit | zmx 0.6.0 / Claude 2.1.210 | Yes for architectural reads: alternate-screen structure and parseable text join to exact roster identities; no mutation/receipt proof | medium-high | research + provider plan + DIS-54 | coordinator made no input/mutation; research worker did not access session | ## Source / Version Ledger @@ -158,7 +164,8 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | Claude Remote Control | 2026-07-15 research preview | explicit external product boundary | documented only; no live mutation | | zmx docs and tagged v0.6.0 source | 2026-07-15 / 0.6.0 | PTY/send/security semantics | reconciled with isolated fake target | | Dispatch source/ADRs | commit 06ae574 baseline | provider/storage/handler/selector seams | audited | -| Crew quick-reply implementation and operating lessons | local `4a24fdb` plus current uncommitted identity hardening | target-safe Agent View UI route and live literal-space behavior; UI send is not receipt | reconciled into DIS-54 candidate; no Dispatch/zmx live proof | +| Crew quick-reply implementation and operating lessons | local `4a24fdb` plus current uncommitted identity hardening | target-safe Agent View UI route and live literal-space behavior; UI send is not receipt | reconciled into DIS-54 candidate; no Dispatch/zmx live mutation proof | +| Coordinator-supplied existing-cockpit observation | zmx 0.6.0 / Claude 2.1.210 | alternate-screen VT and parseable text are available; JSON roster supplies exact identities | read-only architectural evidence; no input, receipts, or transcript capture retained | ## Cleanup Audit @@ -177,7 +184,11 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` sources, and strict empty MCP. Contents were not inspected or retained, and research did not restore or edit the file. The already captured repository-local digest prefix remained 378cd942.... -- Existing Agent View entries and default zmx session were never opened, read, messaged, attached, interrupted, renamed, stopped, or removed. +- The research worker never opened, read, messaged, attached, interrupted, + renamed, stopped, or removed an existing Agent View entry or zmx session. The + coordinator later supplied a bounded read-only structural observation of the + existing cockpit; no input or mutation occurred and no raw screen/transcript + content is retained here. - Claude's documented local transcript retention remains provider-owned. The research did not locate, read, or manually delete transcript files; Agent View rm is not transcript delete. ``` diff --git a/docs/development/claude-provider-plan.md b/docs/development/claude-provider-plan.md index 34ba972..b4993a9 100644 --- a/docs/development/claude-provider-plan.md +++ b/docs/development/claude-provider-plan.md @@ -232,6 +232,14 @@ class CockpitTransport(Protocol): async def attach_info(self) -> AttachState: ... ``` +Read-only coordinator evidence confirms the lower half of this seam on zmx +0.6.0: `history --vt` renders Agent View's alternate screen (cursor, row/status +groups, quick-reply footer, and styling), plain history is parseable, and +`claude agents --json` independently supplies full UUID/name/state/cwd roster +identity. Dispatch must join roster identity to guarded screen state; terminal +row text alone never becomes authority. This does not satisfy the mutation or +receipt gates below. + Required zmx work, all gated by DIS-54: - render a bounded current VT snapshot separately from scrollback/history; diff --git a/docs/research/claude-control-plane-verification.md b/docs/research/claude-control-plane-verification.md index 7a980ad..28d1fb3 100644 --- a/docs/research/claude-control-plane-verification.md +++ b/docs/research/claude-control-plane-verification.md @@ -99,6 +99,7 @@ VT, transaction, and redaction contract. | [zmx docs](https://zmx.sh/) and [tagged source](https://github.com/neurosnap/zmx/tree/v0.6.0) | 0.6.0 | PTY ownership, raw send, no ACK, buffer/drop behavior, modes, input logging | primary source + fake target | | Installed help and probes in `spikes/claude/` | Claude Code 2.1.210 / zmx 0.6.0 | actual flags, event shapes, identity, lifecycle, interrupt, concurrency, failure, cleanup | observed | | Crew `sendClaudeAgentsMessage` and operating lessons | local worktree at `4a24fdb`; committed live quick-reply lesson plus current target-identity hardening | home/row/detail/return/reply guards; literal-space behavior; UI automation is not receipt | implementation + dogfood evidence; Dispatch/zmx proof pending | +| Coordinator-supplied read-only zmx cockpit observation | 2026-07-15 / zmx 0.6.0 | alternate-screen structure and parseable plain history; Agent View JSON supplies full identities for visible blocked rows | non-content architectural evidence from an existing user-owned session; no input or mutation; not delivery proof | The packet's old `/docs/en/cli-reference` URL redirects; the current canonical CLI page is `/docs/en/cli-usage`. @@ -174,13 +175,19 @@ while explicitly warning that UI typing is not delivery proof. Dispatch should host this cockpit in zmx so a human and automation observe one Agent View owner, then use Claude hooks/owned evidence—not zmx status or scrollback—for receipts. -The installed pieces are still insufficient. zmx 0.6.0 provides no revisioned -snapshot, conditional atomic input transaction, named keys, send ACK, or input-log -redaction. Concurrent human keystrokes can invalidate selection between screen -reads; a safe provider must abort on revision change and atomically submit payload -plus Enter under a short UI transaction lease. Even after a PTY write ACK, -acceptance remains blocked until aggregate prompt-hook settlement and owned -provider activity can be corroborated without reading raw transcripts. +The installed pieces are still insufficient. Coordinator-supplied read-only +evidence from an existing user-owned cockpit shows that zmx 0.6.0 `history --vt` +can render Agent View's alternate screen with cursor/style state and that plain +history is structurally parseable; independent `claude agents --json` supplies +the exact full session identities for visible blocked rows. This validates the +API-identity plus VT-choreography composition, but not safe control. zmx still +provides no bounded current-screen revision, conditional atomic input +transaction, named keys, send ACK, or input-log redaction. Concurrent human +keystrokes can invalidate selection between screen reads; a safe provider must +abort on revision change and atomically submit payload plus Enter under a short +UI transaction lease. Even after a PTY write ACK, acceptance remains blocked +until aggregate prompt-hook settlement and owned provider activity can be +corroborated without reading raw transcripts. The regression criterion is one coherent history, not two live processes. An acceptable future transport must prove that the attached human observes the @@ -345,7 +352,7 @@ capability has no acceptable primitive under the pinned versions. | --- | --- | --- | --- | --- | --- | --- | --- | | Exclusive persistent stream-JSON owner | verified across multiple serialized turns while one owner lives | strongest: owned frames, stream activity, aggregate hooks, prompt IDs | no supported attached TUI; concurrent resume splits history | owned process group; after proven exit, resume UUID as a new owner | no extra PTY logger; retain only normalized events | medium | recommended headless base, but does not satisfy seamless coexistence | | Fresh `--resume --print` per turn | verified only with no other live owner | strong per-process stream + aggregate hooks | failed: ordinary TUI/stream owner diverged; Agent View rejected it | simple process lifecycle and restart | no extra PTY logger | low | rejected as the default transport | -| zmx-hosted Agent View cockpit | preserves the Agent View background owner; Crew proves guarded quick-reply UI route | zmx write ACK is transport-only; Claude aggregate hooks + owned activity still required | preferred: human attaches/detaches from same cockpit; revision race must abort | Agent View supervisor owns workers; zmx cockpit restarts without resuming workers | blocked as shipped: 0.6.0 logs input and lacks conditional atomic writes | high | recommended DIS-54 direction; not enable-ready | +| zmx-hosted Agent View cockpit | preserves the Agent View background owner; Crew proves guarded quick-reply UI route; read-only evidence confirms zmx renders Agent View alternate-screen/text state while the roster supplies full IDs | zmx write ACK is transport-only; Claude aggregate hooks + owned activity still required | preferred: human attaches/detaches from same cockpit; revision race must abort | Agent View supervisor owns workers; zmx cockpit restarts without resuming workers | blocked as shipped: 0.6.0 lacks a bounded revisioned current screen and conditional atomic/redacted writes | high | recommended DIS-54 direction; not enable-ready | | zmx-owned worker TUI | one PTY could serialize human and Dispatch input in principle | Claude hooks could confirm after raw injection, but zmx itself has no ACK/order guarantee | possible but duplicates Agent View supervision and attention UI | raw Ctrl-C/loss unconfirmed; persistent process | blocked: 0.6.0 logs raw PTY input and can drop queued bytes | medium-high | fallback investigation, not preferred | | Remote Control / Agent View controls | provider-owned multi-device human continuity | no documented local programmable send RPC | human-facing only | provider reconnection/supervisor semantics | Anthropic relay/product policy | high/product | deferred product decision | From 830b105240d12ac93ea3c6b0651cbd7708304fdf Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:55:05 -0400 Subject: [PATCH 7/9] docs: harden Agent View cockpit plan --- .../RETRO.md | 11 +++ ...control-uses-resume-processes-and-hooks.md | 15 +++-- docs/development/claude-provider-plan.md | 16 ++++- .../claude-control-plane-verification.md | 39 ++++++++--- pyproject.toml | 1 + scripts/check_package_contents.py | 8 +++ spikes/claude/README.md | 7 ++ spikes/claude/zmx_snapshot_probe.sh | 67 +++++++++++++++++++ 8 files changed, 149 insertions(+), 15 deletions(-) create mode 100755 spikes/claude/zmx_snapshot_probe.sh diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index 4453138..fb700be 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -107,6 +107,13 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Independent `claude agents --json` returned exact short/full identities and states for the blocked rows represented in the cockpit. The UI additionally retained completed rows and the quick-reply footer. - This raises confidence in the roster-identity plus guarded-VT composition. It does not prove current-screen revision, atomic/redacted writes, concurrent-human exclusion, Claude receipts, or one shared history; DIS-54 remains blocked and all mutation tests stay disposable-only. +2026-07-15 - Primary Agent View/zmx contract reconciliation +- Official Agent View docs confirm Space-to-peek and Enter-to-reply through the selected session; an ordinary failed/unreachable reply is saved as the session's next prompt, and replying/peeking/attaching to an exited row restarts it from saved state. `claude agents --json --all` supplies completed rows; filter text is navigation only. Agent View remains a research preview. +- Tagged zmx 0.6.0 source confirms `history` serializes the in-memory Ghostty terminal as plain/VT/HTML, PTY output broadcasts to multiple clients, nonleader user input can transfer the resize leader, `send` receives no daemon ACK, the 256 KiB queue drops new overflow bytes without sender error, and debug logs retain raw input with no disable switch. +- Architecture consequence: Claude's supervisor owns reply delivery/queueing, zmx owns guarded UI state/input only, and hooks plus owned activity own receipts. Any possible payload-plus-Enter write remains non-retryable because the supervisor may already have accepted or queued it. +- Ran the new isolated `zmx_snapshot_probe.sh` against `fake_repl.sh` only. Plain, VT, and HTML renders each contained the synthetic target-owned markers; the isolated session and logs were removed. Agent View launch/mutation stayed frozen. +- Hosted Codex review found the repository-gated tests were included in the sdist without their spike assets. Added `/spikes/claude` to the sdist and required every exercised helper/fixture in package-content validation. + 2026-07-15 - Aggregate receipts, transport/security review, and preflight milestone - Transport review round 1 scored 2/5 with 3 P1 and 4 P2 findings. Security/product review round 1 scored 2/5 with 4 P1 and 2 P2 findings. Reports are local scratch under `tmp/reviews/transport/round-1.json` and `tmp/reviews/security-product/round-1.json`. - Blocking sibling prompt hook: the Dispatch observer completed, a sibling `UserPromptSubmit` hook exited 2, no assistant activity or Stop occurred, yet the CLI emitted result subtype success. Therefore observer success and result success do not prove acceptance; processing requires aggregate prompt-hook settlement plus owned-stream assistant/tool activity. @@ -151,6 +158,7 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | cockpit-1 | Is there a target-safe path into the existing Agent View owner? | Read Crew quick-reply implementation, committed live lessons, and current identity guards | Crew commit `4a24fdb` + dirty worktree | Yes as UI composition: exact row, detail/title, return/reselect, literal-space reply, reply-prompt guard, submit | medium-high; direct Dispatch/zmx proof pending | cockpit plan fixture + provider plan | read-only; no process launch | | cockpit-2 | Can installed zmx safely implement the cockpit route? | Compare zmx 0.6.0 help/source/fake failures to Crew console needs | zmx 0.6.0 | No as shipped: lacks revisioned screen, named/conditional atomic input, reliable ACK/errors, and redaction | high | transport table + DIS-54 | no zmx mutation | | cockpit-3 | Can zmx expose enough Agent View screen state to implement guarded reads? | Coordinator-supplied read-only `list`, `history --vt`, plain history, process relation, and independent Agent View JSON roster from an existing user-owned cockpit | zmx 0.6.0 / Claude 2.1.210 | Yes for architectural reads: alternate-screen structure and parseable text join to exact roster identities; no mutation/receipt proof | medium-high | research + provider plan + DIS-54 | coordinator made no input/mutation; research worker did not access session | +| zmx-2 | Does `history` serialize the disposable current internal terminal in all supported formats? | Isolated private zmx session hosting `fake_repl.sh`; assert synthetic markers in plain/VT/HTML | zmx 0.6.0 | Yes for internal terminal/history serialization; no bounded viewport revision or receipt | high | `zmx_snapshot_probe.sh` + tagged source | isolated session/log root removed | ## Source / Version Ledger @@ -243,6 +251,7 @@ No row remains pending or unknown. Full semantics and citations are in | 7 | transport | `tmp/reviews/transport/round-6.json` (replaced with post-correction report) | 5/5 | clean | 0 | cockpit target/lease/revision ordering, receipts, exclusive fallback, loss recovery; zero P0-P3 | | 4 | security/product | `tmp/reviews/security-product/round-3.json` (replaced with post-correction report) | 5/5 | clean | 0 | settings isolation, privacy/redaction, same-UID trust, human race and cleanup; zero P0-P3 | | 4 | full stack | `tmp/reviews/full-stack/round-3.json` (replaced with post-correction report) | 5/5 | clean | 0 | persistent-owner completion, exact blocked projection, recursive content-free gate, boundary alignment; zero P0-P3 | +| 5 | full stack | scoped primary-contract/sdist review | 5/5 | clean after one probe fix | 0 | explicit-CR disposable zmx plain/VT/HTML proof, supervisor/zmx/receipt ownership, sdist assets; zero P0-P3 | ## Verification Log @@ -261,6 +270,8 @@ No row remains pending or unknown. Full semantics and citations are in | post-correction focused fixtures | `uv run pytest -q tests/fixtures/test_claude_research.py` | passed | 4 tests: blocker policy/negative evidence, guarded cockpit plan, persistent-owner completion without process exit, exact current preflight nonce | | post-correction packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; all reports 5/5 clean with zero P0-P2 | | post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 696 passed/17 deselected, wheel/sdist and contents after hosted preflight-nonce fix | +| disposable zmx snapshot | `spikes/claude/zmx_snapshot_probe.sh` | passed | private synthetic target; plain/VT/HTML structural markers; session/log cleanup | +| sdist research assets | `uv build` + `scripts/check_package_contents.py` | passed | exercised Claude helpers and fixtures now ship beside sdist tests | | hosted CI and PR threads | PR #92 | pending | final external closure gate after push | ## Prompt / Goal Alignment diff --git a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md index 8b59967..9eda706 100644 --- a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md +++ b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md @@ -46,11 +46,18 @@ step. Make payload plus Enter one atomic acknowledged transaction; abort before payload if human input changes the VT revision. zmx is terminal transport only, not Claude receipt authority. +Claude's documented Agent View behavior owns reply delivery and queuing: Space +opens peek, Enter submits to the selected session, an ordinary undeliverable +reply is saved as its next prompt, and replying to an exited row restarts the +session through the supervisor. zmx owns only the guarded screen/input path into +that supported UI. Hooks plus owned provider activity remain receipt authority. + This preferred route remains blocked behind DIS-54 until a pinned zmx build -provides bounded current-screen snapshots, named keys, monotonic revisions, -conditional atomic input ACK, nonzero loss/overflow errors, and complete input-log -redaction, and until Agent View exposes aggregate hook settlement plus owned -provider activity without raw transcript reads. +provides bounded current-screen snapshots with viewport/generation metadata, +named keys, monotonic revisions, conditional atomic input ACK, nonzero +loss/overflow errors, complete input-log redaction, and an automation/human lease +that fences zmx leader changes, and until Agent View exposes aggregate hook +settlement plus owned provider activity without raw transcript reads. Implement the verified headless fallback around one exclusive persistent `claude --print --input-format stream-json --output-format stream-json` owner. diff --git a/docs/development/claude-provider-plan.md b/docs/development/claude-provider-plan.md index b4993a9..f1c1dba 100644 --- a/docs/development/claude-provider-plan.md +++ b/docs/development/claude-provider-plan.md @@ -214,6 +214,14 @@ UI sequence against real Agent View: 7. require ordinary Claude acceptance/completion receipts before changing the Dispatch attempt state. +The [official Agent View contract](https://code.claude.com/docs/en/agent-view) +assigns delivery/queueing to Claude's supervisor: Space opens peek, Enter replies +to that session, an ordinary failed/unreachable reply is retained as its next +prompt, and an exited row can be replied to and restarted from saved state. +Therefore a possible payload-plus-Enter write is never automatically retried: it +may already be live or supervisor-queued. zmx is the guarded UI substrate; hooks +and owned provider activity decide Dispatch acceptance/completion. + This is UI automation, not an RPC. The console provider must fail closed at every screen guard and must never fall back to unguarded raw send. @@ -243,13 +251,15 @@ receipt gates below. Required zmx work, all gated by DIS-54: - render a bounded current VT snapshot separately from scrollback/history; -- assign a monotonic revision to every screen/input state change; +- assign viewport metadata and a monotonic generation/revision to every + screen/input state change; - support named keys and text without shell encoding; - serialize and acknowledge a conditional multi-input transaction only after the bytes enter the PTY queue; - make payload plus Enter one atomic batch; -- expose a short exclusive automation lease; any human input increments revision - and causes a stale transaction to abort before payload; +- expose a short exclusive automation lease across revision and zmx leader state; + any human input or leader transfer increments revision and causes a stale + transaction to abort before payload; - disable raw-input logging by construction and test that logs contain no input bytes; - return nonzero typed loss/overflow errors; never silently drop queued input. diff --git a/docs/research/claude-control-plane-verification.md b/docs/research/claude-control-plane-verification.md index 28d1fb3..706b540 100644 --- a/docs/research/claude-control-plane-verification.md +++ b/docs/research/claude-control-plane-verification.md @@ -142,10 +142,23 @@ identity. A background session can move from its launch cwd into a Claude-manage worktree before editing, so effective cwd is observed state, not immutable launch metadata. -Human reply is supported through Agent View peek or an attached TUI. Shell -management is limited to list, attach, logs, stop/kill, respawn, and rm. `logs` -is diagnostic output. `rm` removes the Agent View entry/worktree but deliberately -leaves the local conversation resumable; it is not archive or transcript delete. +The official [Agent View documentation](https://code.claude.com/docs/en/agent-view) +defines the provider-owned reply path: `Space` opens peek and `Enter` sends its +reply to the selected session. If an ordinary reply cannot reach the background +service or send fails, Claude saves it as that session's next prompt; `!` Bash +replies are the documented exception. A row whose process exited remains +peekable/replyable/attachable and the supervisor restarts it from saved state. +These are supervisor product semantics, not a public reply RPC or a Dispatch +receipt. + +`claude agents --json --all` includes completed rows and supplies the roster +fields used for identity. UI filters (`a:`, `s:`, PR/URL) may help +navigation but never replace the full UUID join. Agent View is a research preview +whose UI/shortcuts can change, so the adapter must pin/version-gate every guard. +Shell management remains limited to list, attach, logs, stop/kill, respawn, and +rm. `logs` is diagnostic output. `rm` removes the Agent View entry/worktree but +deliberately leaves the local conversation resumable; it is not archive or +transcript delete. ### Ownership and coexistence @@ -249,10 +262,20 @@ The isolated fake target confirmed the primary-source contract: - after session kill, raw send printed an unresponsive error and exited zero; - successful send therefore proves neither target acceptance nor completion. -zmx also maintains a 256 KiB PTY input queue whose overflow can drop input -without sender notification. Version 0.6.0 logs PTY input bytes at debug level -and does not expose a disable/redaction control. Private modes reduce who can -read the logs but do not satisfy a no-raw-prompt-retention boundary. +[zmx v0.6.0 source](https://github.com/neurosnap/zmx/blob/v0.6.0/src/main.zig) +keeps one Ghostty terminal and PTY per session and broadcasts PTY output to all +clients. `history` serializes that internal terminal as plain text, VT, or HTML; +it is stronger than a file-tail read but still lacks a bounded viewport and +monotonic snapshot generation. Input from a nonleader client can transfer the +leader, which alone controls PTY/Ghostty resize, so automation needs a lease +around both revision and leader state. + +`send` writes an `Input` IPC frame and returns without a daemon acknowledgement. +The daemon queues at most 256 KiB; overflow drops the new payload without an error +to the sender, though a local warning is logged. Version 0.6.0 fixes logging at +debug, records raw PTY input bytes (potentially twice), and exposes file modes but +no disable/redaction control. Private modes reduce who can read the logs but do +not satisfy a no-raw-prompt-retention boundary. ## Lifecycle state machine diff --git a/pyproject.toml b/pyproject.toml index 01b444b..99bf0cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ exclude = ["/src/**/AGENTS.md"] include = [ "/src", "/tests", + "/spikes/claude", "/scripts", "/skills", "/plugins/dispatch", diff --git a/scripts/check_package_contents.py b/scripts/check_package_contents.py index acfa079..1c14ab1 100644 --- a/scripts/check_package_contents.py +++ b/scripts/check_package_contents.py @@ -39,6 +39,14 @@ def _check_sdist(path: Path) -> None: "plugins/dispatch/skills/dm/SKILL.md", "plugins/dispatch/README.md", "plugins/dispatch/.mcp.json", + "spikes/claude/assert_probe.py", + "spikes/claude/sanitize_stream.jq", + "spikes/claude/zmx_snapshot_probe.sh", + "spikes/claude/fixtures/capability-policy.json", + "spikes/claude/fixtures/agent-view-cockpit-plan.jsonl", + "spikes/claude/fixtures/coexistence-outcomes.jsonl", + "spikes/claude/fixtures/persistent-owner-completion.jsonl", + "spikes/claude/fixtures/preflight-nonce-raw.jsonl", } missing = sorted(required - names) if missing: diff --git a/spikes/claude/README.md b/spikes/claude/README.md index ebb9758..54290dc 100644 --- a/spikes/claude/README.md +++ b/spikes/claude/README.md @@ -400,6 +400,13 @@ printf '%s\n' "$lost_output" | rg -qi 'unresponsive|not found' test "$(zmx list --short | rg -c "^$zmx_name$" || true)" -eq 0 ``` +Run `spikes/claude/zmx_snapshot_probe.sh` for the separate content-free read +probe. It starts only `fake_repl.sh` in a unique private zmx directory, requires +the same synthetic acceptance/completion markers in zmx's plain, VT, and HTML +renderings, removes the session/log directory, and emits booleans only. This +confirms that v0.6.0 serializes its internal terminal in all three formats; it +does not prove a bounded revisioned viewport, safe input, or Claude receipts. + ## Process-group fixture Run `uv run python spikes/claude/process_group_probe.py`. It starts diff --git a/spikes/claude/zmx_snapshot_probe.sh b/spikes/claude/zmx_snapshot_probe.sh new file mode 100755 index 0000000..dabc382 --- /dev/null +++ b/spikes/claude/zmx_snapshot_probe.sh @@ -0,0 +1,67 @@ +#!/bin/sh +set -eu + +command -v zmx >/dev/null +command -v jq >/dev/null +command -v rg >/dev/null + +case "$(zmx version)" in + *0.6.0*) ;; + *) printf '%s\n' 'zmx 0.6.0 required' >&2; exit 2 ;; +esac + +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +probe_root=$(mktemp -d /tmp/dispatch-zmx-snapshot.XXXXXX) +export ZMX_DIR="$probe_root/zmx" +export ZMX_DIR_MODE=0700 +export ZMX_LOG_MODE=0600 +name="dispatch-snapshot-$$" +client_pid= + +cleanup() { + zmx kill "$name" >/dev/null 2>&1 || true + if [ -n "$client_pid" ]; then + wait "$client_pid" 2>/dev/null || true + fi + rm -rf "$probe_root" +} +trap cleanup EXIT INT TERM + +zmx attach "$name" "$root/fake_repl.sh" /dev/null 2>&1 & +client_pid=$! + +i=0 +while [ "$i" -lt 50 ]; do + zmx list --short 2>/dev/null | rg -qx "$name" && break + i=$((i + 1)) + sleep 0.05 +done +zmx list --short | rg -qx "$name" + +printf 'snapshot\r' | zmx send "$name" +i=0 +while [ "$i" -lt 50 ]; do + zmx history "$name" 2>/dev/null | rg -q 'completed:snapshot' && break + i=$((i + 1)) + sleep 0.05 +done + +plain=$(zmx history "$name") +vt=$(zmx history "$name" --vt) +html=$(zmx history "$name" --html) +for rendered in "$plain" "$vt" "$html"; do + printf '%s' "$rendered" | rg -q 'accepted:snapshot' + printf '%s' "$rendered" | rg -q 'completed:snapshot' +done + +zmx kill "$name" >/dev/null +wait "$client_pid" 2>/dev/null || true +client_pid= +if zmx list --short | rg -q "^$name$"; then + printf '%s\n' "isolated zmx session remained after cleanup: $name" >&2 + exit 1 +fi +rm -rf "$probe_root" +trap - EXIT INT TERM + +jq -cn '{version:"0.6.0",target:"synthetic",plain:true,vt:true,html:true,cleanup:true}' From a42b544af42be482c1f25af66256706d22512fa0 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 16:57:57 -0400 Subject: [PATCH 8/9] docs: finalize Claude control research --- .../RETRO.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index fb700be..3eec9c1 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -1,8 +1,8 @@ # Execution Retro: Claude Control-Plane Research Date started: 2026-07-15 -Date finalized: pending hosted CI and PR thread closure -Status: Local gates and post-correction reviews clean; hosted closure pending +Date finalized: 2026-07-15 +Status: Complete; ready research PR open and unmerged Spec: `.agents/goals/2026-07-15-claude-control-plane-research/SPEC.md` Goal: `.agents/goals/2026-07-15-claude-control-plane-research/GOAL.md` Prompt: `.agents/goals/2026-07-15-claude-control-plane-research/PROMPT.md` @@ -272,7 +272,7 @@ No row remains pending or unknown. Full semantics and citations are in | post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 696 passed/17 deselected, wheel/sdist and contents after hosted preflight-nonce fix | | disposable zmx snapshot | `spikes/claude/zmx_snapshot_probe.sh` | passed | private synthetic target; plain/VT/HTML structural markers; session/log cleanup | | sdist research assets | `uv build` + `scripts/check_package_contents.py` | passed | exercised Claude helpers and fixtures now ship beside sdist tests | -| hosted CI and PR threads | PR #92 | pending | final external closure gate after push | +| hosted CI and PR threads | PR #92 at `830b105` | passed | repository check, CodeQL actions/python, CodeQL aggregate, Graphite, and Cursor passed; zero unresolved threads; Codex packaging P2 fixed/replied/resolved before usage limit | ## Prompt / Goal Alignment @@ -283,7 +283,7 @@ No row remains pending or unknown. Full semantics and citations are in | Item | State | Notes | | --- | --- | --- | -| DIS-9 | In Progress | Description corrected to aggregate receipt findings; implementation order comment added; close only after ready research PR | +| DIS-9 | Done | Research closure comment records verdict, primary-source/zmx proof, gates, cleanup, ordered implementation, and unmerged PR | | DIS-48 | Todo | Adjacent usage-capture lifecycle; not messaging transport | | DIS-49 | Todo | Provider selector/CLI shorthand issue retained; blocked by DIS-50 and related to later slices | | DIS-50 | Backlog | High-priority exclusive-headless walking skeleton; blocked by DIS-1/2/3 and DIS-54 | @@ -306,8 +306,10 @@ No row remains pending or unknown. Full semantics and citations are in - Review summary: post-coexistence transport round 7, security/product round 4, and full-stack round 4 are each 5/5 clean with zero P0-P3. - Verification summary: packet, fixture, lint/type, sanitizer privacy, - process-group, negative receipt checks, and post-correction local `just check` - pass with 696 tests; hosted PR CI and thread closure are pending. + process-group, negative receipt checks, isolated zmx snapshot serialization, + sdist contents, and post-correction local `just check` pass with 696 tests. + Hosted checks pass on the final research commit and all review threads are + resolved. - Cleanup audit: research-created Agent View/zmx/process/temp resources removed; repository-local settings unchanged; Claude's Agent View launch mutated the user settings file despite isolation flags, and research neither read nor From ef932c7d83b89ec96b46033f2fcffe8fc6aee8ce Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Wed, 15 Jul 2026 17:13:00 -0400 Subject: [PATCH 9/9] docs: define global Claude cockpit topology --- .../RETRO.md | 16 ++++- ...control-uses-resume-processes-and-hooks.md | 27 ++++++--- docs/development/claude-provider-plan.md | 59 ++++++++++++------- .../claude-control-plane-verification.md | 36 +++++++---- spikes/claude/README.md | 11 +++- spikes/claude/assert_probe.py | 18 ++++++ .../fixtures/agent-view-cockpit-plan.jsonl | 5 +- 7 files changed, 124 insertions(+), 48 deletions(-) diff --git a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md index 3eec9c1..7bee010 100644 --- a/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md +++ b/.agents/goals/2026-07-15-claude-control-plane-research/RETRO.md @@ -114,6 +114,11 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` - Ran the new isolated `zmx_snapshot_probe.sh` against `fake_repl.sh` only. Plain, VT, and HTML renders each contained the synthetic target-owned markers; the isolated session and logs were removed. Agent View launch/mutation stayed frozen. - Hosted Codex review found the repository-gated tests were included in the sdist without their spike assets. Added `/spikes/claude` to the sdist and required every exercised helper/fixture in package-content validation. +2026-07-15 - Global Agent View cockpit topology correction +- Production uses one shared unscoped `claude agents` cockpit per local Claude runtime. `--cwd` remains only a disposable-test or explicit operator filter; per-repository cockpit proliferation is rejected. +- Unscoped `claude agents --json --all` is the global identity roster. Cwd/worktree remains mutable routing metadata, preserving cross-repository visibility rather than defining cockpit ownership. +- Guarded selection joins provider-qualified full session UUID, current cwd/worktree, and exactly one visible row. Missing, duplicate, or ambiguous joins fail closed before input; the executable cockpit-plan fixture enforces this contract. + 2026-07-15 - Aggregate receipts, transport/security review, and preflight milestone - Transport review round 1 scored 2/5 with 3 P1 and 4 P2 findings. Security/product review round 1 scored 2/5 with 4 P1 and 2 P2 findings. Reports are local scratch under `tmp/reviews/transport/round-1.json` and `tmp/reviews/security-product/round-1.json`. - Blocking sibling prompt hook: the Dispatch observer completed, a sibling `UserPromptSubmit` hook exited 2, no assistant activity or Stop occurred, yet the CLI emitted result subtype success. Therefore observer success and result success do not prove acceptance; processing requires aggregate prompt-hook settlement plus owned-stream assistant/tool activity. @@ -155,7 +160,7 @@ Refs: `.agents/goals/2026-07-15-claude-control-plane-research/REFS.md` | coexist-1 | Does an attached ordinary TUI stay coherent during external resume-send? | Isolated TUI turn, external resume completion, later human turn, post-exit resume visibility check | Claude Code 2.1.210 | No; TUI stayed alive but did not inherit/display external turn; later resume followed TUI branch without external marker | high | coexistence fixture + research doc | TUI exited; temp removed | | coexist-2 | Does Agent View preserve ownership during external resume-send? | Background owner at done/idle, retry detached and attached, human turn, stop then resume | Claude Code 2.1.210 | Yes by exclusion: live owner rejected resume exit 1; attached human continued; post-stop resume succeeded | high | coexistence fixture + research doc | stopped/removed | | coexist-3 | Can one persistent stream owner carry multiple turns and share with external resume? | Two framed owner messages, concurrent resume, later owner visibility check | Claude Code 2.1.210 | Multi-turn owner verified; shared ownership failed because external turn was absent from continuing owner | high | coexistence fixture + research doc | owner exited; temp removed | -| cockpit-1 | Is there a target-safe path into the existing Agent View owner? | Read Crew quick-reply implementation, committed live lessons, and current identity guards | Crew commit `4a24fdb` + dirty worktree | Yes as UI composition: exact row, detail/title, return/reselect, literal-space reply, reply-prompt guard, submit | medium-high; direct Dispatch/zmx proof pending | cockpit plan fixture + provider plan | read-only; no process launch | +| cockpit-1 | Is there a target-safe path into the existing Agent View owner? | Read Crew quick-reply implementation, committed live lessons, and current identity guards | Crew commit `4a24fdb` + dirty worktree | Yes as UI composition: global roster + provider/session/cwd/visible-row join, detail/title, return/reselect, literal-space reply, reply-prompt guard, submit; ambiguous rows fail closed | medium-high; direct Dispatch/zmx proof pending | cockpit plan fixture + provider plan | read-only; no process launch | | cockpit-2 | Can installed zmx safely implement the cockpit route? | Compare zmx 0.6.0 help/source/fake failures to Crew console needs | zmx 0.6.0 | No as shipped: lacks revisioned screen, named/conditional atomic input, reliable ACK/errors, and redaction | high | transport table + DIS-54 | no zmx mutation | | cockpit-3 | Can zmx expose enough Agent View screen state to implement guarded reads? | Coordinator-supplied read-only `list`, `history --vt`, plain history, process relation, and independent Agent View JSON roster from an existing user-owned cockpit | zmx 0.6.0 / Claude 2.1.210 | Yes for architectural reads: alternate-screen structure and parseable text join to exact roster identities; no mutation/receipt proof | medium-high | research + provider plan + DIS-54 | coordinator made no input/mutation; research worker did not access session | | zmx-2 | Does `history` serialize the disposable current internal terminal in all supported formats? | Isolated private zmx session hosting `fake_repl.sh`; assert synthetic markers in plain/VT/HTML | zmx 0.6.0 | Yes for internal terminal/history serialization; no bounded viewport revision or receipt | high | `zmx_snapshot_probe.sh` + tagged source | isolated session/log root removed | @@ -211,7 +216,7 @@ No row remains pending or unknown. Full semantics and citations are in | new | verified | headless qualifier: exclusive persistent `--session-id UUID --print` stream owner | persist lane/message; hook preflight; no frame written; no other owner | aggregate prompt settlement + activity; final settled Stop cycle + terminal per-message result | retry only when no frame write began | observed 2.1.210, high | | exclusive headless owner | verified | persistent stream-JSON process; post-exit `--resume UUID` replacement | owned identity; correct cwd; proven no other owner | aggregate prompt/activity receipts and terminal per-message result; clean exit on owner shutdown | second owner is rejected by Dispatch; ownership uncertainty blocks sends | observed/docs 2.1.210, high | | human Agent View attach | verified | human `attach SHORT_ID` | known Agent View entry and operator TTY | UI plus ordinary hooks; not Dispatch transport | no shell reply RPC | observed 2.1.210, high | -| preserve attached human while Dispatch sends | blocked | no safe supported shared-owner primitive | n/a | none sufficient | ordinary TUI/stream owner split history; Agent View rejects resume; zmx 0.6.0 fails privacy/receipt gates; DIS-54 | observed 2.1.210/0.6.0, high | +| preserve attached human while Dispatch sends | blocked | candidate is one global unscoped zmx-hosted Agent View cockpit; global roster joins provider/session + cwd/worktree + one visible row | DIS-54 hardened zmx, unique row, automation lease, no per-repo fallback | aggregate prompt settlement + owned activity; final settled Stop cycle + terminal per-message result | ambiguous rows fail closed; ordinary TUI/stream owner split history; Agent View rejects resume; zmx 0.6.0 fails privacy/receipt gates | observed 2.1.210/0.6.0, high | | Dispatch attach unmanaged ordinary UUID | unsupported | none proven | n/a | none | do not grant writable authority from UUID alone | surface audit, high | | headless send | verified | one framed message at a time through exclusive persistent stream owner | owned identity; preflight; owner and one-writer leases | processing after terminal non-blocking hook settlement/activity; completion after final Stop settlement/terminal per-message result | possible-write loss is indeterminate; never auto-retry; never resume beside a live owner | observed 2.1.210, high | | steer during active turn | unsupported | no documented print-process RPC | n/a | none | typed unsupported; do not relabel queue | official/local audit, high | @@ -270,6 +275,8 @@ No row remains pending or unknown. Full semantics and citations are in | post-correction focused fixtures | `uv run pytest -q tests/fixtures/test_claude_research.py` | passed | 4 tests: blocker policy/negative evidence, guarded cockpit plan, persistent-owner completion without process exit, exact current preflight nonce | | post-correction packet doctor | goal packet plus three current clean reports | passed | prompt 3,994/4,000; all reports 5/5 clean with zero P0-P2 | | post-correction repository gate | `just check` | passed | Ruff, format, strict mypy, 696 passed/17 deselected, wheel/sdist and contents after hosted preflight-nonce fix | +| global-cockpit topology fixture | focused fixture, shell, sdist, and package-content gates | passed | unscoped cockpit/global roster, provider-session-cwd-row join, and ambiguous-row abort enforced | +| global-cockpit full repository gate | `just check` | passed | Ruff, format, strict mypy, 696 passed/17 deselected, wheel/sdist and contents | | disposable zmx snapshot | `spikes/claude/zmx_snapshot_probe.sh` | passed | private synthetic target; plain/VT/HTML structural markers; session/log cleanup | | sdist research assets | `uv build` + `scripts/check_package_contents.py` | passed | exercised Claude helpers and fixtures now ship beside sdist tests | | hosted CI and PR threads | PR #92 at `830b105` | passed | repository check, CodeQL actions/python, CodeQL aggregate, Graphite, and Cursor passed; zero unresolved threads; Codex packaging P2 fixed/replied/resolved before usage limit | @@ -290,7 +297,10 @@ No row remains pending or unknown. Full semantics and citations are in | DIS-51 | Backlog | High-priority receipt reduction and generation recovery hardening; blocked by DIS-50 | | DIS-52 | Backlog | High-priority queue, attention, and destructive indeterminate-attempt resolution; blocked by DIS-50/51 | | DIS-53 | Backlog | Metadata-only Agent View evaluation; blocked by DIS-50/52; never grants authority | -| DIS-54 | Backlog | Preferred zmx-hosted Agent View cockpit prerequisite; Crew route identified; zmx transaction/redaction and aggregate receipt proof block DIS-50 enablement/acceptance | +| DIS-54 | Backlog | Preferred single global unscoped zmx-hosted Agent View cockpit prerequisite; global roster and fail-closed target join specified; zmx transaction/redaction and aggregate receipt proof block DIS-50 enablement/acceptance | + +DIS-54 comment `a37d74a2-5600-48ae-ba4e-6959ee11e0d8` records the global +cockpit topology and fail-closed routing acceptance amendment. ## Final State diff --git a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md index 9eda706..8120f2e 100644 --- a/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md +++ b/docs/adrs/0026-claude-control-uses-resume-processes-and-hooks.md @@ -38,13 +38,22 @@ that cockpit for Dispatch. ## Decision -Prefer one persistent zmx-hosted `claude agents` cockpit for human-coexistent -control while Agent View retains background-worker ownership. Port Crew's guarded -quick-reply route onto revisioned VT snapshots and serialized conditional input. -Require exact roster/session identity and screen guards before every navigation -step. Make payload plus Enter one atomic acknowledged transaction; abort before -payload if human input changes the VT revision. zmx is terminal transport only, -not Claude receipt authority. +Prefer one persistent, unscoped zmx-hosted `claude agents` cockpit for +human-coexistent control while Agent View retains background-worker ownership. +This is one global shared cockpit per local Claude runtime, not one cockpit per +repository. Production launches omit `--cwd`; that flag is reserved for +disposable tests or an explicit operator filter. Treat the unscoped global +roster from `claude agents --json --all` as identity input and cwd/worktree as +mutable routing metadata, not a cockpit boundary. + +Port Crew's guarded quick-reply route onto revisioned VT snapshots and serialized +conditional input. Resolve a target by joining provider-qualified full session +UUID, current cwd/worktree metadata, and exactly one visible row. A missing, +duplicate, or ambiguous join fails closed before input. Require exact +roster/session identity and screen guards before every navigation step. Make +payload plus Enter one atomic acknowledged transaction; abort before payload if +human input changes the VT revision. zmx is terminal transport only, not Claude +receipt authority. Claude's documented Agent View behavior owns reply delivery and queuing: Space opens peek, Enter submits to the selected session, an ordinary undeliverable @@ -131,7 +140,9 @@ There is no silent fallback or forced Codex semantic parity. ### Positive - Uses supported direct CLI/Agent View primitives without private endpoints; - the coexistence candidate adds one explicit persistent cockpit PTY. + the coexistence candidate adds one explicit global persistent cockpit PTY. +- Preserves cross-repository visibility and each session's cwd/worktree routing + metadata without proliferating per-repository cockpits. - Durable UUID and provider prompt ID give stable routing and exact cycle joins. - Persistent stream ownership preserves coherent headless multi-turn state and avoids the observed per-turn resume split when a second owner is present. diff --git a/docs/development/claude-provider-plan.md b/docs/development/claude-provider-plan.md index f1c1dba..cfb4b9b 100644 --- a/docs/development/claude-provider-plan.md +++ b/docs/development/claude-provider-plan.md @@ -11,10 +11,11 @@ Decision: [ADR-0026](../adrs/0026-claude-control-uses-resume-processes-and-hooks Add Claude as a fixed second execution provider beneath Dispatch's authored ops. The preferred coexistence target preserves Claude's Agent View background owner and drives its guarded quick-reply UI through one persistent zmx-hosted -`claude agents` cockpit. Humans attach/detach from that same cockpit; Dispatch -never creates a competing resume owner. zmx supplies terminal snapshots and -input transactions only. Claude hooks plus owned provider activity remain the -receipt authority. +unscoped `claude agents` cockpit. This is one global shared cockpit per local +Claude runtime, never one cockpit per repository. Humans attach/detach from that +same cockpit; Dispatch never creates a competing resume owner. zmx supplies +terminal snapshots and input transactions only. Claude hooks plus owned provider +activity remain the receipt authority. Installed zmx 0.6.0 cannot implement that safely as shipped, and Agent View has not exposed the required aggregate receipt stream. DIS-54 must add/prove the @@ -57,6 +58,13 @@ or renamed approximations. - Agent View quick reply is the preferred human-coexistent send candidate. Crew proves the guarded UI route, but Dispatch still needs pinned zmx and receipt evidence before capability projection can mark it supported. +- The production cockpit launches as unscoped `claude agents`, and identity + discovery uses unscoped `claude agents --json --all`. `--cwd` is only a + disposable-test or explicit operator filter; Dispatch does not create + per-repository cockpits. +- Cwd/worktree is routing metadata. Target selection joins provider-qualified + full session UUID, current cwd/worktree, and exactly one visible Agent View + row; zero or multiple matches fail closed before any input. - Ordinary TUI plus external resume and persistent stream owner plus external resume both split history. Agent View rejects resume while its owner lives. Dispatch must never start a second owner merely because the first is idle. @@ -203,15 +211,18 @@ Initial Claude capabilities: Crew's `sendClaudeAgentsMessage` and operating lessons establish the target-safe UI sequence against real Agent View: -1. resolve the shared cockpit and the target from `claude agents --json` using - the full session UUID as authority; -2. normalize to the Agent View home/list and locate one exact visible row; -3. select it, open detail, and verify the intended title/session identity; -4. return to home and verify the same row remains selected; -5. send a literal text space—not a named Space key—and wait for both `❯ reply` +1. resolve the one global shared cockpit and obtain the global identity roster + from unscoped `claude agents --json --all`; +2. join the provider-qualified full session UUID and current cwd/worktree + metadata to exactly one visible row; fail closed on missing, duplicate, or + ambiguous matches; +3. normalize to the Agent View home/list and locate that exact visible row; +4. select it, open detail, and verify the intended title/session identity; +5. return to home and verify the same row remains selected; +6. send a literal text space—not a named Space key—and wait for both `❯ reply` and `space to close`; -6. atomically type the payload and Enter; -7. require ordinary Claude acceptance/completion receipts before changing the +7. atomically type the payload and Enter; +8. require ordinary Claude acceptance/completion receipts before changing the Dispatch attempt state. The [official Agent View contract](https://code.claude.com/docs/en/agent-view) @@ -243,9 +254,11 @@ class CockpitTransport(Protocol): Read-only coordinator evidence confirms the lower half of this seam on zmx 0.6.0: `history --vt` renders Agent View's alternate screen (cursor, row/status groups, quick-reply footer, and styling), plain history is parseable, and -`claude agents --json` independently supplies full UUID/name/state/cwd roster -identity. Dispatch must join roster identity to guarded screen state; terminal -row text alone never becomes authority. This does not satisfy the mutation or +unscoped `claude agents --json --all` independently supplies the global full +UUID/name/state/cwd roster. Dispatch must join provider/session identity and +current cwd/worktree metadata to exactly one guarded visible row; terminal row +text or cwd alone never becomes authority. Duplicate/ambiguous rows return a +typed routing conflict before input. This does not satisfy the mutation or receipt gates below. Required zmx work, all gated by DIS-54: @@ -411,11 +424,13 @@ View metadata can prove its background owner exists, but cannot authorize a Dispatch send into that owner unless the guarded cockpit capability is enabled. For the cockpit route, daemon or zmx loss does not stop Agent View background -workers. Restart the cockpit, re-resolve the full session UUID from the roster, -and repeat every home/detail/return/reply guard. A UI transaction lost after any -possible write is `frame_maybe_written`; never replay it. If a human attach or -keystroke changes the VT revision, abort before payload and return an -operator-visible `cockpit_changed` conflict. +workers. Restart the one global unscoped cockpit, refresh the global `claude +agents --json --all` roster, re-resolve the full session UUID plus current +cwd/worktree to exactly one visible row, and repeat every +home/detail/return/reply guard. A UI transaction lost after any possible write +is `frame_maybe_written`; never replay it. If a human attach or keystroke changes +the VT revision, abort before payload and return an operator-visible +`cockpit_changed` conflict. ## Hook/settings strategy @@ -791,6 +806,10 @@ new provider columns. - prove interrupt/restart and explicit owner handoff without transcript reads; - implement/prove the preferred persistent zmx-hosted Agent View cockpit using Crew's target-safe quick-reply sequence; +- launch one global unscoped `claude agents` cockpit, use unscoped global-roster + identity discovery via `claude agents --json --all`, and prove + provider/session + cwd/worktree + visible-row joins fail closed on duplicate + or ambiguous rows; - add revisioned VT snapshot, named keys, serialized conditional input ACK, atomic payload-plus-Enter, automation lease, nonzero loss/overflow, and complete input-log redaction to a pinned zmx build; diff --git a/docs/research/claude-control-plane-verification.md b/docs/research/claude-control-plane-verification.md index 706b540..f85a79b 100644 --- a/docs/research/claude-control-plane-verification.md +++ b/docs/research/claude-control-plane-verification.md @@ -10,8 +10,11 @@ Claude Agent SDK Dispatch can support durable, multi-turn **headless** Claude sessions. For human-coexistent control, Crew supplies a concrete preferred design: keep the Agent View background owner intact, host one persistent `claude agents` cockpit -in zmx, and drive Claude's own guarded quick-reply UI. zmx is the terminal/VT -substrate, never the Claude receipt protocol. +in zmx, and drive Claude's own guarded quick-reply UI. Production uses one +global unscoped cockpit, not one cockpit per repository; the unscoped output of +`claude agents --json --all` is the global identity roster, with cwd/worktree +retained as routing metadata. zmx is the terminal/VT substrate, never the Claude +receipt protocol. That route is not yet verified end to end for Dispatch. Installed zmx 0.6.0 lacks revisioned VT snapshots, named-key input, acknowledged/serialized conditional @@ -151,9 +154,13 @@ peekable/replyable/attachable and the supervisor restarts it from saved state. These are supervisor product semantics, not a public reply RPC or a Dispatch receipt. -`claude agents --json --all` includes completed rows and supplies the roster -fields used for identity. UI filters (`a:`, `s:`, PR/URL) may help -navigation but never replace the full UUID join. Agent View is a research preview +Unscoped `claude agents --json --all` includes completed rows and supplies the +global roster fields used for identity. Production starts one unscoped cockpit +with `claude agents`. `--cwd` is a disposable-test or explicit operator filter, +not a default topology or authority boundary. UI filters (`a:`, `s:`, +PR/URL) may help navigation but never replace the provider-qualified full UUID + +current cwd/worktree + visible-row join. Zero or multiple matches fail closed +before input. Agent View is a research preview whose UI/shortcuts can change, so the adapter must pin/version-gate every guard. Shell management remains limited to list, attach, logs, stop/kill, respawn, and rm. `logs` is diagnostic output. `rm` removes the Agent View entry/worktree but @@ -185,8 +192,10 @@ existing sessions: Agent View quick reply is a real UI primitive into the existing background owner. Crew proves the guarded navigation shape and the critical literal-space behavior, while explicitly warning that UI typing is not delivery proof. Dispatch should -host this cockpit in zmx so a human and automation observe one Agent View owner, -then use Claude hooks/owned evidence—not zmx status or scrollback—for receipts. +host one global unscoped cockpit in zmx so a human and automation observe one +Agent View owner across repositories, then use Claude hooks/owned evidence—not +zmx status or scrollback—for receipts. Per-repository cockpit proliferation would +fragment supervision and is outside the recommended topology. The installed pieces are still insufficient. Coordinator-supplied read-only evidence from an existing user-owned cockpit shows that zmx 0.6.0 `history --vt` @@ -321,7 +330,7 @@ capability has no acceptable primitive under the pinned versions. | New | verified | headless qualifier: persist UUID, spawn exclusive persistent `--session-id UUID --print` stream owner, settle preflight; optional `--bg` is separate human-supervised mode | prompt hook settlement + owned activity; final settled Stop cycle + terminal per-message result | retry only when proven no frame write began; otherwise operator reconciliation | high | | Post-exit resume owner | verified | Dispatch starts one `--resume UUID` stream owner only after proven prior-owner exit; never managed `--continue` | `SessionStart(source=resume)` then ordinary aggregate receipts | stale/wrong-cwd/owner-conflict are typed failures | high | | Human Agent View attach | verified | human `claude attach SHORT_ID` to a known disposable Agent View entry | UI and ordinary hooks; not a Dispatch send transport | human supervision only; no shell reply RPC | high | -| Preserve attached human while Dispatch sends | blocked | preferred candidate: zmx-hosted Agent View cockpit + target-safe quick reply; same background owner remains authoritative | not yet sufficient: guarded PTY write needs aggregate hook settlement + owned provider activity | DIS-54 adds revisioned snapshot/atomic input/redaction and pinned live proof; abort on human revision race | medium-high design confidence; Dispatch proof blocked | +| Preserve attached human while Dispatch sends | blocked | preferred candidate: one global unscoped zmx-hosted Agent View cockpit + target-safe quick reply; global `--json --all` roster joins provider/session + cwd/worktree + one visible row | not yet sufficient: guarded PTY write needs aggregate hook settlement + owned provider activity | DIS-54 adds revisioned snapshot/atomic input/redaction and pinned live proof; abort on human revision race or ambiguous row | medium-high design confidence; Dispatch proof blocked | | Dispatch attach of an unmanaged ordinary session | unsupported | no content-free metadata validation primitive was proven | none | do not register writable authority from UUID alone | high | | Headless send | verified | serialized messages through one exclusive persistent stream-JSON owner; fresh `--resume UUID` creates the next owner only after proven prior-owner exit | processing after aggregate prompt settlement + activity; completion after final Stop settlement/result; process exit is required when the owner terminates | any possible frame write plus loss is indeterminate; never auto-retry; reject second owners | high | | Steer active turn | unsupported | no documented print-process steer RPC; TUI input semantics are not equivalent | none | expose unsupported, do not queue under a steer label | high | @@ -359,7 +368,8 @@ capability has no acceptable primitive under the pinned versions. | Fresh resume while ordinary TUI attached | both processes can complete but histories diverge; external turn was absent from the TUI and later resume | Claude runtime + operator | mark ownership conflict; stop sends; explicit human handoff/reconciliation; never claim shared continuity | | Fresh resume while stream owner lives | external process can complete but its turn is absent from the continuing owner | Dispatch supervisor | reject second owner by durable lease/process identity; after uncertainty, block until operator resolves ownership | | Fresh resume while Agent View owner lives | exit 1, owner remains coherent and human-usable | Agent View | attach for human use or stop owner before a later resume; no Dispatch send during ownership | -| Cockpit target/detail/reply guard mismatch | Crew path fails closed before payload | cockpit reducer | renormalize home and retry only before any payload write; never fall back to raw send | +| Cockpit target/detail/reply guard mismatch | Crew path fails closed before payload | cockpit reducer | refresh global roster, renormalize home, and retry only before any payload write; never fall back to raw send | +| Duplicate/ambiguous global cockpit row | provider/session + cwd/worktree does not join to exactly one visible row | cockpit reducer | return typed routing conflict before input; do not filter into or create a per-repo cockpit | | Human changes cockpit revision | target/input state may have moved | hardened zmx transaction | abort before payload with `cockpit_changed`; reacquire snapshot and re-run every identity guard | | Cockpit loss after possible payload write | PTY write/Enter may have reached Agent View | cockpit transport + operator | acceptance indeterminate; no automatic retry; wait for aggregate receipts or explicitly abandon | | Cockpit restart | Agent View background owner continues independently | zmx supervisor | restart cockpit only, re-resolve full UUID/row, then re-run guards; never resume the worker | @@ -375,7 +385,7 @@ capability has no acceptable primitive under the pinned versions. | --- | --- | --- | --- | --- | --- | --- | --- | | Exclusive persistent stream-JSON owner | verified across multiple serialized turns while one owner lives | strongest: owned frames, stream activity, aggregate hooks, prompt IDs | no supported attached TUI; concurrent resume splits history | owned process group; after proven exit, resume UUID as a new owner | no extra PTY logger; retain only normalized events | medium | recommended headless base, but does not satisfy seamless coexistence | | Fresh `--resume --print` per turn | verified only with no other live owner | strong per-process stream + aggregate hooks | failed: ordinary TUI/stream owner diverged; Agent View rejected it | simple process lifecycle and restart | no extra PTY logger | low | rejected as the default transport | -| zmx-hosted Agent View cockpit | preserves the Agent View background owner; Crew proves guarded quick-reply UI route; read-only evidence confirms zmx renders Agent View alternate-screen/text state while the roster supplies full IDs | zmx write ACK is transport-only; Claude aggregate hooks + owned activity still required | preferred: human attaches/detaches from same cockpit; revision race must abort | Agent View supervisor owns workers; zmx cockpit restarts without resuming workers | blocked as shipped: 0.6.0 lacks a bounded revisioned current screen and conditional atomic/redacted writes | high | recommended DIS-54 direction; not enable-ready | +| Global unscoped zmx-hosted Agent View cockpit | preserves the Agent View background owner across repositories; global `--json --all` roster joins provider/session + cwd/worktree to one guarded visible row | zmx write ACK is transport-only; Claude aggregate hooks + owned activity still required | preferred: human attaches/detaches from the same global cockpit; revision/ambiguity races abort | Agent View supervisor owns workers; zmx cockpit restarts without resuming workers | blocked as shipped: 0.6.0 lacks a bounded revisioned current screen and conditional atomic/redacted writes | high | recommended DIS-54 direction; no per-repo cockpits; not enable-ready | | zmx-owned worker TUI | one PTY could serialize human and Dispatch input in principle | Claude hooks could confirm after raw injection, but zmx itself has no ACK/order guarantee | possible but duplicates Agent View supervision and attention UI | raw Ctrl-C/loss unconfirmed; persistent process | blocked: 0.6.0 logs raw PTY input and can drop queued bytes | medium-high | fallback investigation, not preferred | | Remote Control / Agent View controls | provider-owned multi-device human continuity | no documented local programmable send RPC | human-facing only | provider reconnection/supervisor semantics | Anthropic relay/product policy | high/product | deferred product decision | @@ -407,8 +417,10 @@ capability has no acceptable primitive under the pinned versions. as content-bearing. They are outside default receipt ingestion. - Do not enable Remote Control automatically. It changes external routing, authentication, availability, and policy boundaries. -- Session names, short IDs, titles, and cwd are selectors/metadata, never - authorization. Route on `(provider, full session UUID)` plus local lane key. +- Session names, short IDs, titles, and cwd/worktree are selectors/metadata, + never authorization. Route on `(provider, full session UUID)` plus local lane + key, then require current cwd/worktree and exactly one visible global-cockpit + row before UI input. ## Contradictions and confidence limits diff --git a/spikes/claude/README.md b/spikes/claude/README.md index 54290dc..f2a8d45 100644 --- a/spikes/claude/README.md +++ b/spikes/claude/README.md @@ -56,9 +56,14 @@ ordered aggregate receipts. `agent-view-cockpit-plan.jsonl` is a design fixture derived from Crew's guarded Agent View quick-reply route, not a new live Dispatch proof. It requires exact -roster/row identity, home/detail/return/reply viewport guards, revision-conditional -serialized input, one atomic payload-plus-Enter batch, and abort on concurrent -human revision change. It also deliberately retains the receipt blocker: +global-roster/row identity using provider + full session UUID + cwd/worktree + +one visible row, fail-closed duplicate/ambiguity handling, +home/detail/return/reply viewport guards, revision-conditional serialized input, +one atomic payload-plus-Enter batch, and abort on concurrent human revision +change. The production topology is one unscoped `claude agents` cockpit with +unscoped `claude agents --json --all` discovery; `--cwd` below is only a +disposable-test/cleanup filter. The fixture also deliberately retains the +receipt blocker: Agent View has not yet exposed aggregate sibling-hook settlement plus owned provider activity to Dispatch without raw transcript access. `just check` runs these fixtures through `tests/fixtures/test_claude_research.py`. diff --git a/spikes/claude/assert_probe.py b/spikes/claude/assert_probe.py index 4bca191..5ecc876 100644 --- a/spikes/claude/assert_probe.py +++ b/spikes/claude/assert_probe.py @@ -260,9 +260,22 @@ def assert_cockpit_plan_fixture(events: list[Event]) -> None: "lease_released", "lease_acquired", "concurrent_human_revision_changed", + "ambiguous_target_rejected", "receipt_blocker", ] assert events[0].get("event") == "target_resolved" + assert events[0].get("cockpit_scope") == "global_unscoped" + assert events[0].get("cockpit_command") == "claude agents" + assert events[0].get("roster_command") == "claude agents --json --all" + assert events[0].get("target_join") == [ + "provider", + "full_session_id", + "cwd_worktree", + "visible_row", + ] + assert events[0].get("cwd_worktree_verified") is True + assert events[0].get("visible_row_match_count") == 1 + assert events[0].get("per_repo_cockpit") is False assert events[0].get("full_session_id_verified") is True required_guards = ( @@ -337,6 +350,11 @@ def assert_content_free(value: Any) -> None: assert_content_free(events) + assert events[14].get("event") == "ambiguous_target_rejected" + assert events[14].get("visible_row_match_count") == 2 + assert events[14].get("action") == "abort_before_input" + assert events[14].get("per_repo_fallback") is False + second_lease = events[12] human_race = events[13] assert second_lease.get("exclusive") is True diff --git a/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl b/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl index 348a925..6a93762 100644 --- a/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl +++ b/spikes/claude/fixtures/agent-view-cockpit-plan.jsonl @@ -1,4 +1,4 @@ -{"sequence":1,"event":"target_resolved","full_session_id_verified":true,"roster_source":"claude_agents_json","selector":"synthetic-short-id"} +{"sequence":1,"event":"target_resolved","cockpit_scope":"global_unscoped","cockpit_command":"claude agents","roster_source":"claude_agents_json_all_unscoped","roster_command":"claude agents --json --all","provider":"claude","full_session_id_verified":true,"cwd_worktree_verified":true,"visible_row_match_count":1,"target_join":["provider","full_session_id","cwd_worktree","visible_row"],"per_repo_cockpit":false,"selector":"synthetic-short-id"} {"sequence":2,"event":"home_verified","value":true,"snapshot_revision":10,"target_row_visible":true} {"sequence":3,"event":"lease_acquired","lease_id":"00000000-0000-4000-8000-000000000301","at_revision":10,"exclusive":true} {"sequence":4,"event":"input_ack","input":"named_key_down","expected_revision":10,"result_revision":11,"lease_id":"00000000-0000-4000-8000-000000000301","serialized":true,"raw_input_logged":false} @@ -12,4 +12,5 @@ {"sequence":12,"event":"lease_released","lease_id":"00000000-0000-4000-8000-000000000301","at_revision":18} {"sequence":13,"event":"lease_acquired","lease_id":"00000000-0000-4000-8000-000000000302","at_revision":20,"exclusive":true} {"sequence":14,"event":"concurrent_human_revision_changed","lease_id":"00000000-0000-4000-8000-000000000302","expected_revision":20,"actual_revision":21,"lease_invalidated":true,"action":"abort_before_payload"} -{"sequence":15,"event":"receipt_blocker","aggregate_hook_settlement":"unproven","owned_provider_activity":"unproven","capability":"blocked"} +{"sequence":15,"event":"ambiguous_target_rejected","visible_row_match_count":2,"action":"abort_before_input","per_repo_fallback":false} +{"sequence":16,"event":"receipt_blocker","aggregate_hook_settlement":"unproven","owned_provider_activity":"unproven","capability":"blocked"}