The multiplatform terminal wrapper is the backbone for hosting agent sessions. Supersedes the sister project's planned "capsule" (which leaned on psmux). Architecturally a **process supervisor** with an abstract session surface. **The session-surface *mechanism* is delivered in M3a (`crates/spt-term`); the supervisor *process* (term-daemon) is delivered in M3b (`crates/spt-daemon`, 2026-06-03).**

-A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b — hosts many M3a `spt-term` surfaces behind versioned IPC; `spt-term` itself is deliberately single-surface-per-handle.)*

+A long-lived, one-per-machine daemon that owns the PTYs for all hosted sessions, multiplexes them (sessions addressable by name, tmux-style), and accepts attach/detach from frontends. A hosted agent session (a LiveAgent's Self, a ReadyAgent, etc.) runs *inside* a supervised PTY. **Headed** = a frontend is attached and rendering/driving the PTY; **headless** = the session runs with no frontend attached. The daemon's persistence is what lets a session keep running while unwatched and be re-attached "on a whim." *(M3b ✅ — `spt-daemon` hosts many M3a `spt-term` surfaces behind a versioned local IPC, split into a stable **broker** (holds the PTYs/children/sockets) + a restartable **brain** (all logic) so a brain swap leaves hosted sessions gapless; `spt-term` itself is deliberately single-surface-per-handle.)*

**session surface** (library abstraction):

The trait spt-core exposes over "anything you can write input to and read output from" — delivered in M3a as `spt_term::SessionSurface` (`write_input` + `resize`, with `send_keys`/`send_line` as first-class injection). Native PTY (`spt_term::PtySession`: ConPTY on Windows 10+, `forkpty(3)` on Unix, via the `portable-pty` crate) is the day-one implementation. The abstraction generalizes so future implementations — network-attached surfaces (for `spt-node` remote control), GUI text panes, etc. — are uniform to consumers. Pre-Windows-10 (winpty) is explicitly dropped. **OS-neutral by contract (Spike #4/#5):** the trait bakes in neither `forkpty`'s raw-ordered-pipe nor ConPTY's repaint-on-resize screen-buffer semantics; output is a plain byte stream (`spt_term::OutputStream`, bounded backpressure) with OS-independent replay.


Author M3c-PLAN.md stub.


Write

# M3c Plan — peer-propagated signed self-update (STUB)

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a/M3b-PLAN.md`.
> This is a **stub**: the ordered task layer is authored in full when M3c starts.
> Branch: `dev-freeform`. Authoritative architecture: **ADR-0004 §A** (update-class
> taxonomy) + **§D** (update hardening) and `M3-PLAN.md` §M3c.

> **Upstream is done:** **M3b ✅ delivered (2026-06-03)** — the broker/brain split is
> real, so the load-bearing precondition for a zero-interruption self-update exists:
> a brain swap behind the versioned IPC already leaves hosted PTY children + their
> output streams intact and gapless (the daemon E2E + `handoff.rs` prove it). M3c
> builds the **update engine** that drives that swap deliberately, signed and gated.

## Goal

Ship the gated, signed, ripple-capable **self-update engine** — the routine
brain-only update that swaps logic with **zero endpoint interruption**
(`REQ-UPD-3`, absolute), verified end-to-end, plus the hardening that makes
accepting a new binary safe (signature, monotonic version, metadata expiry). The
**P2P transport** that propagates updates peer-to-peer is M4; M3c ships the engine
with **self-fetch / out-of-band** delivery and plugs the M4 transport into the same
seam later (`REQ-UPD-1` activates at M4).

## Scope

### In

- **Update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint terminates —
  `REQ-UPD-3`, the routine case, proven E2E over the M3b handoff path) ·
  **broker-compatible** (hot-swap behind the versioned IPC the M3b
  `REQ-HAZARD-HANDOFF-ARGV-COMPAT` handshake already enables) · **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling).
- **Signature verification before handoff** (`REQ-UPD-2`) — spt-core's release key,
  on every binary regardless of source; no unverified binary is ever exec'd.
- **Update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**.
- **Consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
  most-recently-active live session; opt-in full-auto.
- **Adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts the
  stack: self first, then each registered adapter via its manifest `[update]` avenue
  (file-pull / delegated command), with adapter content signing.

### Out

- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 `spt-net`
  layer; M3c ships self-fetch / out-of-band delivery into the same engine seam.
- whole-daemon live FD-passing (zero-interruption for *broker-breaking* updates) —
  explicit future polish, not v1 (ADR-0004 consequences).

## Requirements (activate per task when M3c starts)

`REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`, `REQ-HAZARD-UPDATE-ROLLBACK` — all registered,
currently `required_stages = []`. `REQ-UPD-1` stays `[]` until M4. No new
requirements anticipated (TRACEABILITY rule 3 assessment: **none** — re-confirm at
task-authoring time).

## Clean-room posture

Clean-room the engine: the sister used a GitHub remote + `gh`; spt-core self-fetches
+ verifies signatures, with the P2P transport arriving at M4 (ADR-0002/0004). Reuse
the M3b broker/brain swap path — M3c *triggers* a handoff, it does not re-implement
one.

## Tasks (to be expanded at M3c start)

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| C0 | Update-class engine + brain-only zero-interruption swap (E2E over the M3b handoff path) | REQ-UPD-3 | a brain-only update swaps the binary with no hosted endpoint interruption, proven E2E |
| C1 | Signature verification before handoff + monotonic-version rollback rejection | REQ-UPD-2, REQ-HAZARD-UPDATE-ROLLBACK | an unsigned / older-version / expired-metadata binary is refused; a valid one is accepted |
| C2 | Consent gating (confirm to most-recently-active live session; opt-in full-auto) | REQ-UPD-4 | default-gated; full-auto is explicit opt-in |
| C3 | Adapter ripple-update + adapter content signing | REQ-UPD-5, REQ-SEAM-UPDATE | self-then-adapters conducted via each manifest `[update]` avenue, signed |
| C4 | Activation sweep + E2E; amend ROADMAP (M3c delivered) + CONTEXT; M4 handoff note | — | `check` green with M3c reqs activated; clippy `-D warnings`; CI matrix green |

## Workspace change

Likely **no new crate** — the update engine lives in `spt-daemon` (it drives the
brain/broker swap) with the signing/verification primitives in `spt-store` or a small
`update` module; confirm the home at task-authoring time. P2P delivery is M4
(`spt-net`).


File created successfully at: C:\Users\decid\Documents\projects\spt-core\M3c-PLAN.md (file state is current in your context — no need to Read it back)

# M3c Plan — peer-propagated signed self-update (STUB)

> **Just-in-time, lightweight** — same pattern as `M0/M1/M2a/M2b/M3a/M3b-PLAN.md`.
> This is a **stub**: the ordered task layer is authored in full when M3c starts.
> Branch: `dev-freeform`. Authoritative architecture: **ADR-0004 §A** (update-class
> taxonomy) + **§D** (update hardening) and `M3-PLAN.md` §M3c.

> **Upstream is done:** **M3b ✅ delivered (2026-06-03)** — the broker/brain split is
> real, so the load-bearing precondition for a zero-interruption self-update exists:
> a brain swap behind the versioned IPC already leaves hosted PTY children + their
> output streams intact and gapless (the daemon E2E + `handoff.rs` prove it). M3c
> builds the **update engine** that drives that swap deliberately, signed and gated.

## Goal

Ship the gated, signed, ripple-capable **self-update engine** — the routine
brain-only update that swaps logic with **zero endpoint interruption**
(`REQ-UPD-3`, absolute), verified end-to-end, plus the hardening that makes
accepting a new binary safe (signature, monotonic version, metadata expiry). The
**P2P transport** that propagates updates peer-to-peer is M4; M3c ships the engine
with **self-fetch / out-of-band** delivery and plugs the M4 transport into the same
seam later (`REQ-UPD-1` activates at M4).

## Scope

### In

- **Update-class taxonomy** (ADR-0004 §A): **brain-only** (no endpoint terminates —
  `REQ-UPD-3`, the routine case, proven E2E over the M3b handoff path) ·
  **broker-compatible** (hot-swap behind the versioned IPC the M3b
  `REQ-HAZARD-HANDOFF-ARGV-COMPAT` handshake already enables) · **broker-breaking**
  (planned endpoint-cycle, may suspend with consent + scheduling).
- **Signature verification before handoff** (`REQ-UPD-2`) — spt-core's release key,
  on every binary regardless of source; no unverified binary is ever exec'd.
- **Update hardening** (`REQ-HAZARD-UPDATE-ROLLBACK`, ADR-0004 §D) — monotonic
  version (rollback rejection), release-metadata expiry, channel pinning, key
  rotation/revocation list, **adapter content signing**.
- **Consent gating** (`REQ-UPD-4`) — gated on user confirmation delivered to the
  most-recently-active live session; opt-in full-auto.
- **Adapter ripple-update** (`REQ-UPD-5` + `REQ-SEAM-UPDATE`) — spt-core conducts the
  stack: self first, then each registered adapter via its manifest `[update]` avenue
  (file-pull / delegated command), with adapter content signing.

### Out

- **Peer-propagation transport over P2P** (`REQ-UPD-1`) — needs the M4 `spt-net`
  layer; M3c ships self-fetch / out-of-band delivery into the same engine seam.
- whole-daemon live FD-passing (zero-interruption for *broker-breaking* updates) —
  explicit future polish, not v1 (ADR-0004 consequences).

## Requirements (activate per task when M3c starts)

`REQ-UPD-2/3/4/5`, `REQ-SEAM-UPDATE`, `REQ-HAZARD-UPDATE-ROLLBACK` — all registered,
currently `required_stages = []`. `REQ-UPD-1` stays `[]` until M4. No new
requirements anticipated (TRACEABILITY rule 3 assessment: **none** — re-confirm at
task-authoring time).

## Clean-room posture

Clean-room the engine: the sister used a GitHub remote + `gh`; spt-core self-fetches
+ verifies signatures, with the P2P transport arriving at M4 (ADR-0002/0004). Reuse
the M3b broker/brain swap path — M3c *triggers* a handoff, it does not re-implement
one.

## Tasks (to be expanded at M3c start)

| # | Task | Reqs / hazards | Acceptance |
|---|------|----------------|------------|
| C0 | Update-class engine + brain-only zero-interruption swap (E2E over the M3b handoff path) | REQ-UPD-3 | a brain-only update swaps the binary with no hosted endpoint interruption, proven E2E |
| C1 | Signature verification before handoff + monotonic-version rollback rejection | REQ-UPD-2, REQ-HAZARD-UPDATE-ROLLBACK | an unsigned / older-version / expired-metadata binary is refused; a valid one is accepted |
| C2 | Consent gating (confirm to most-recently-active live session; opt-in full-auto) | REQ-UPD-4 | default-gated; full-auto is explicit opt-in |
| C3 | Adapter ripple-update + adapter content signing | REQ-UPD-5, REQ-SEAM-UPDATE | self-then-adapters conducted via each manifest `[update]` avenue, signed |
| C4 | Activation sweep + E2E; amend ROADMAP (M3c delivered) + CONTEXT; M4 handoff note | — | `check` green with M3c reqs activated; clippy `-D warnings`; CI matrix green |

## Workspace change

Likely **no new crate** — the update engine lives in `spt-daemon` (it drives the
brain/broker swap) with the signing/verification primitives in `spt-store` or a small
`update` module; confirm the home at task-authoring time. P2P delivery is M4
(`spt-net`).


Write B9 daemon E2E test

tests/daemon_e2e.rs: spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff. int tags for DAEMON-1/2/4 + DAEMON-HOSTED-LIVENESS.

Activate M3b reqs in traceable-reqs.toml

Flip required_stages to add int for DAEMON-1, DAEMON-2, DAEMON-4, DAEMON-HOSTED-LIVENESS, RESTART-IDEMPOTENT. Run traceable-reqs check green.

Verify workspace: cargo test + clippy + check

cargo test --workspace, clippy -D warnings, traceable-reqs check green. Linux via gravity-linux if reachable.

Amend ROADMAP + CONTEXT, author M3c stub

Mark M3b delivered in ROADMAP.md; amend CONTEXT.md; author M3c-PLAN.md stub (self-update).

Commit B9 with traceability trailer

Single B9 commit: E2E test + req activation + docs. Co-Authored-By trailer. Ensure CI matrix expectation noted.


Docs done. Verify check still green + grab the project's commit trailer.


Bash

cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1 && echo "CHECK OK" || echo "CHECK FAIL"; echo "===TRAILER==="; git log -1 --format='%(trailers)' HEAD; echo "===STATUS==="; git status --short

Verify check + read commit trailer + status


CHECK OK
===TRAILER===
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

===STATUS===
 M CONTEXT.md
 M ROADMAP.md
 M traceable-reqs.toml
?? M3c-PLAN.md
?? crates/spt-daemon/tests/daemon_e2e.rs


NOTE FROM THEUSER: proceed with next steps.


Updated task #4 status


Updated task #5 status


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git commit -q -F - <<'EOF'
feat(daemon): B9 — M3b activation sweep + daemon E2E (REQ-DAEMON-1/2/4 int)

Final M3b task. Activate the daemon int stages and prove the consolidated
broker/brain architecture end-to-end through the real IPC.

- tests/daemon_e2e.rs: spawn→Psyche-loop→commune→brain-restart-survives→
  graceful-signoff in one flow. A Broker hosts a PTY child; a BrainLifecycle
  hosts the re-hosted M2b lifecycle; the brain is killed+restarted 10× — the
  child pid stays stable, the output stream replays gaplessly, gen_start
  advances each handoff, and the (stateless-on-disk) lifecycle keeps ingesting
  communes straight across each restart; graceful signoff runs echo-before-
  teardown (3.3) and removes the Self ready marker. A dead summarizer pid does
  not flip the daemon-hosted Psyche offline (liveness = status, not pid).
- traceable-reqs.toml: activate int for REQ-DAEMON-1/2/4,
  REQ-HAZARD-DAEMON-HOSTED-LIVENESS, REQ-HAZARD-RESTART-IDEMPOTENT.
- ROADMAP + CONTEXT: M3b marked delivered (2026-06-03); M3c is next.
- M3c-PLAN.md: self-update stub authored.

Windows: cargo test --workspace green, clippy -D warnings clean,
traceable-reqs check green. Linux (ubuntu) via the CI matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit B9 with trailer


warning: in the working copy of 'CONTEXT.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'ROADMAP.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'M3c-PLAN.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-daemon/tests/daemon_e2e.rs', LF will be replaced by CRLF the next time Git touches it
20817a6 feat(daemon): B9 — M3b activation sweep + daemon E2E (REQ-DAEMON-1/2/4 int)


Committed locally (`20817a6`). Task 5 done.


M3b complete = milestone. Commune the delta to Psyche.


Write

# Commune — B9 done, M3b COMPLETE

**B9 landed (20817a6).** Final M3b task done → **M3b milestone delivered (2026-06-03).**

What B9 did:
- `crates/spt-daemon/tests/daemon_e2e.rs` — the milestone E2E: spawn→Psyche-loop→commune→brain-restart-survives(10×)→graceful-signoff in one flow over the real IPC. Weaves Broker/Brain PTY-restart (gapless, gen_start advances) with BrainLifecycle (Psyche spawn, pulse ingest across restart, echo-before-teardown signoff). Dead summarizer pid does NOT flip daemon-hosted perch offline (liveness=status).
- Activated int: REQ-DAEMON-1/2/4, REQ-HAZARD-DAEMON-HOSTED-LIVENESS, REQ-HAZARD-RESTART-IDEMPOTENT → all `[impl,unit,int]`.
- ROADMAP + CONTEXT amended (M3b ✅); `M3c-PLAN.md` stub authored.

Gates (Windows): `cargo test --workspace` green, clippy `-D warnings` clean, `traceable-reqs check` green.

**Open / next:**
- **NOT pushed** — commit is local on `dev-freeform`. CI matrix (ubuntu+windows) Linux leg unrun until push. That's the one remaining external B9 acceptance item; waiting on user to push (or explicit ask).
- **Next milestone: M3c** — peer-propagated signed self-update (engine: update-class taxonomy, sig-verify, rollback hardening, consent gate, adapter ripple; P2P transport deferred to M4). Stub ready in `M3c-PLAN.md`.
- M3 nearly closed: only M3c remains, then M4 (networking — the real cross-node proof).


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

# Commune — B9 done, M3b COMPLETE

**B9 landed (20817a6).** Final M3b task done → **M3b milestone delivered (2026-06-03).**

What B9 did:
- `crates/spt-daemon/tests/daemon_e2e.rs` — the milestone E2E: spawn→Psyche-loop→commune→brain-restart-survives(10×)→graceful-signoff in one flow over the real IPC. Weaves Broker/Brain PTY-restart (gapless, gen_start advances) with BrainLifecycle (Psyche spawn, pulse ingest across restart, echo-before-teardown signoff). Dead summarizer pid does NOT flip daemon-hosted perch offline (liveness=status).
- Activated int: REQ-DAEMON-1/2/4, REQ-HAZARD-DAEMON-HOSTED-LIVENESS, REQ-HAZARD-RESTART-IDEMPOTENT → all `[impl,unit,int]`.
- ROADMAP + CONTEXT amended (M3b ✅); `M3c-PLAN.md` stub authored.

Gates (Windows): `cargo test --workspace` green, clippy `-D warnings` clean, `traceable-reqs check` green.

**Open / next:**
- **NOT pushed** — commit is local on `dev-freeform`. CI matrix (ubuntu+windows) Linux leg unrun until push. That's the one remaining external B9 acceptance item; waiting on user to push (or explicit ask).
- **Next milestone: M3c** — peer-propagated signed self-update (engine: update-class taxonomy, sig-verify, rollback hardening, consent gate, adapter ripple; P2P transport deferred to M4). Stub ready in `M3c-PLAN.md`.
- M3 nearly closed: only M3c remains, then M4 (networking — the real cross-node proof).


**B9 done. M3b milestone COMPLETE.** Commit `20817a6` (local, `dev-freeform`).

What landed:
- **`tests/daemon_e2e.rs`** — milestone E2E: spawn→Psyche-loop→commune→brain-restart-survives(10×)→graceful-signoff, one flow over real IPC. Broker/Brain PTY survives brain kill gaplessly, `gen_start` advances; lifecycle keeps ingesting communes across each restart; dead summarizer pid does NOT flip daemon-hosted perch offline.
- **traceable-reqs.toml** — activated `int` for REQ-DAEMON-1/2/4 + DAEMON-HOSTED-LIVENESS + RESTART-IDEMPOTENT → all `[impl,unit,int]`.
- **ROADMAP + CONTEXT** — M3b ✅ delivered (2026-06-03).
- **M3c-PLAN.md** — self-update stub authored.

Gates green (Windows): `cargo test --workspace` (all pass incl. new E2E), clippy `-D warnings` clean, `traceable-reqs check` exit 0.

**One open item:** commit NOT pushed (push only on ask). CI matrix **ubuntu** leg unrun — that's the last B9 acceptance gate. Push `dev-freeform` to validate Linux, or tell me to.

**Next: M3c** — signed self-update engine. M3 then closes; M4 = networking (real cross-node proof). Communed delta to Psyche.


push to run ubuntu CI


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


Bash

cd C:/Users/decid/Documents/projects/spt-core && git remote -v && echo "===BRANCH===" && git rev-parse --abbrev-ref HEAD && git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>&1

Check remote + upstream tracking


origin	https://github.com/SaberMage/spt-core.git (fetch)
origin	https://github.com/SaberMage/spt-core.git (push)
===BRANCH===
dev-freeform
origin/dev-freeform


Upstream set. Push.


cd C:/Users/decid/Documents/projects/spt-core && git push origin dev-freeform 2>&1 | tail -10

Push dev-freeform to origin


To https://github.com/SaberMage/spt-core.git
   c848ebd..20817a6  dev-freeform -> dev-freeform


Pushed `c848ebd..20817a6` (B7/B8/B9 — origin was behind). Check CI run.


cd C:/Users/decid/Documents/projects/spt-core && gh run list --branch dev-freeform --limit 3 2>&1 | head -10

List recent CI runs


completed	failure	docs(spike-07): in-process gix true number (~4.7ms/commit) via attrac…	ci	dev-freeform	push	26869794881	2m4s	2026-06-03T07:18:51Z
completed	failure	docs(spike-07): BranchStore git-KV bench — PASS, coarse/hot boundary …	ci	dev-freeform	push	26868689855	8m56s	2026-06-03T06:52:34Z
completed	failure	docs: adopt BranchStore (git-KV) as the coarse/durable state substrat…	ci	dev-freeform	push	26868520920	2m21s	2026-06-03T06:48:29Z


New run may not be registered yet. Recent runs show failures — but those are older commits (spike-07). Wait for the 20817a6 run.


cd C:/Users/decid/Documents/projects/spt-core && sleep 12 && gh run list --branch dev-freeform --limit 2 --json databaseId,headSha,status,conclusion,displayTitle 2>&1

Poll for new CI run on pushed commit


feat(daemon): B9 — M3b activation sweep + daemon E2E (REQ-DAEMON-1/2/…

docs(spike-07): in-process gix true number (~4.7ms/commit) via attrac…


B9 run in_progress (`26898581198`). Prior c848ebd failed — inspect why (could recur). Check its failed jobs.


cd C:/Users/decid/Documents/projects/spt-core && gh run view 26869794881 --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}' 2>&1 | head -20

Inspect prior failed run jobs


{"conclusion":"success","name":"traceability"}
{"conclusion":"failure","name":"test (ubuntu-latest)"}
{"conclusion":"failure","name":"test (windows-latest)"}


Both test legs failed on c848ebd (pre-B6 tree). Need the actual error — may persist into B9. Get failed log.


cd C:/Users/decid/Documents/projects/spt-core && gh run view 26869794881 --log-failed 2>&1 | grep -iE 'error|panic|FAILED|cannot|failed to|test result' | head -30

Extract failure lines from prior ubuntu run


test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:18:59.1411796Z   curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail https://sh.rustup.rs | sh -s -- --default-toolchain none -y
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:08.9470777Z ##[group]Run : work around spurious network errors in curl 8.0
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:08.9471138Z : work around spurious network errors in curl 8.0
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:28.2142124Z test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:28.2180292Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:28.9270719Z test cli::tests::invalid_usage_errors ... ok
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:28.9287032Z test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.6270853Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.70s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.6476227Z test effect::tests::failed_effect_is_not_recorded_applied ... ok
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.6504059Z test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8689276Z test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8706648Z test broker_hosts_pty_child_streams_output_and_accepts_input ... FAILED
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8707407Z error: test failed, to rerun pass `-p spt-daemon --test broker`
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8729250Z thread 'broker_hosts_pty_child_streams_output_and_accepts_input' (2680) panicked at crates/spt-daemon/tests/broker.rs:55:38:
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8752632Z test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test (ubuntu-latest)	UNKNOWN STEP	2026-06-03T07:19:29.8761398Z ##[error]Process completed with exit code 101.
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:19:07.9683083Z   curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail https://win.rustup.rs/x86_64 --output 'D:\a\_temp\rustup-init.exe'
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:19:27.6693960Z ##[group]Run : work around spurious network errors in curl 8.0
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:19:27.6694612Z : work around spurious network errors in curl 8.0
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:47.2911718Z test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:47.3123910Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:47.8248829Z test cli::tests::invalid_usage_errors ... ok
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:47.8298068Z test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.50s
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:48.8271288Z test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.98s
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:48.8897031Z test effect::tests::failed_effect_is_not_recorded_applied ... ok
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:48.9193213Z test frame::tests::unknown_kind_is_not_a_decode_error ... ok
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:49.5202792Z test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.67s
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:49.5456926Z test broker_hosts_pty_child_streams_output_and_accepts_input ... FAILED
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:49.5462365Z thread 'broker_hosts_pty_child_streams_output_and_accepts_input' (4040) panicked at crates\spt-daemon\tests\broker.rs:55:38:
test (windows-latest)	UNKNOWN STEP	2026-06-03T07:20:49.5463796Z bind broker: Os { code: 3, kind: NotFound, message: "The system cannot find the path specified." }


CI failure found — pre-existing, inherited by B9. `Broker::bind` fails on BOTH OSes in CI: `broker.rs:55` panic, Windows msg `Os code:3 NotFound "cannot find the path specified"`. All daemon tests (incl my E2E) call `Broker::bind` → B9 CI will fail same way. Read transport to find the socket-name bug.


1	//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
2	//! (unix), behind one [`DaemonTransport`] trait.
3	//!
4	//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
5	//! the broker and brain talk through one trait, and the platform split lives in
6	//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
7	//! crate's local sockets — namespaced names work on both Windows and Linux).
8	//! The byte framing ([`crate::codec`]) and the version handshake
9	//! ([`crate::frame`]) ride on top, transport-agnostic.
10	// [impl->REQ-DAEMON-2]
11
12	use std::io::{self, Read, Write};
13
14	use interprocess::local_socket::{
15	    prelude::*, GenericNamespaced, ListenerOptions, Stream,
16	};
17
18	use crate::codec::{read_frame, write_frame};
19	use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};
20
21	/// A bidirectional IPC connection between the broker and a brain. Anything that
22	/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
23	/// in-memory or socket-pair duplex for the real local socket.
24	pub trait IpcConn: Read + Write {}
25	impl<T: Read + Write> IpcConn for T {}
26
27	/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
28	/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
29	/// drive the broker over a stub transport in tests without a real socket.
30	pub trait DaemonTransport {
31	    /// The accepted/connected connection type.
32	    type Conn: IpcConn;
33
34	    /// Accept the next inbound connection (broker side). Blocks.
35	    fn accept(&self) -> io::Result<Self::Conn>;
36	}
37
38	/// The production transport: `interprocess` local sockets. A namespaced name
39	/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
40	/// OS split, contained here.
41	pub struct LocalSocketTransport {
42	    listener: interprocess::local_socket::Listener,
43	}
44
45	impl LocalSocketTransport {
46	    /// Bind a listening endpoint under the namespaced `name` (broker side).
47	    pub fn bind(name: &str) -> io::Result<Self> {
48	        let ns = name.to_ns_name::<GenericNamespaced>()?;
49	        let listener = ListenerOptions::new().name(ns).create_sync()?;
50	        Ok(LocalSocketTransport { listener })
51	    }
52
53	    /// Connect to an existing endpoint under `name` (brain side).
54	    pub fn connect(name: &str) -> io::Result<Stream> {
55	        let ns = name.to_ns_name::<GenericNamespaced>()?;
56	        Stream::connect(ns)
57	    }
58	}
59
60	impl DaemonTransport for LocalSocketTransport {
61	    type Conn = Stream;
62
63	    fn accept(&self) -> io::Result<Self::Conn> {
64	        self.listener.accept()
65	    }
66	}
67
68	/// Brain side of the connect handshake: announce ourselves as `role` by sending
69	/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
70	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
71	pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
72	    let hello = Hello::current(role);
73	    let payload =
74	        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
75	    write_frame(w, &Envelope::new("hello", payload))
76	}
77
78	/// Broker side: read the peer's first frame, require it to be a `hello`, and
79	/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
80	/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
81	/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
82	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
83	pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
84	    let env = read_frame(r)?;
85	    if env.kind != "hello" {
86	        return Err(io::Error::new(
87	            io::ErrorKind::InvalidData,
88	            format!("expected hello frame, got kind {:?}", env.kind),
89	        ));
90	    }
91	    let hello: Hello = serde_json::from_value(env.payload)
92	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
93	    accept_hello(&hello, expected_peer)
94	        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
95	    Ok(hello)
96	}
97
98	#[cfg(test)]
99	mod tests {
100	    use super::*;
101	    use serde_json::json;
102	    use std::sync::atomic::{AtomicU32, Ordering};
103
104	    // Unique namespaced socket name per test, so parallel test threads on one
105	    // machine never collide on the same pipe/UDS name.
106	    static SEQ: AtomicU32 = AtomicU32::new(0);
107	    fn unique_name() -> String {
108	        let n = SEQ.fetch_add(1, Ordering::Relaxed);
109	        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
110	    }
111
112	    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
113	    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
114	    #[test]
115	    fn local_socket_handshake_and_frame_round_trip() {
116	        let name = unique_name();
117	        let broker = LocalSocketTransport::bind(&name).unwrap();
118
119	        let client_name = name.clone();
120	        let client = std::thread::spawn(move || {
121	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
122	            send_hello(&mut conn, Role::Brain).unwrap();
123	            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
124	            // Read the broker's reply.
125	            read_frame(&mut conn).unwrap()
126	        });
127
128	        let mut server = broker.accept().unwrap();
129	        let hello = recv_hello(&mut server, Role::Brain).unwrap();
130	        assert_eq!(hello.role, Role::Brain);
131	        let ping = read_frame(&mut server).unwrap();
132	        assert_eq!(ping.kind, "ping");
133	        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();
134
135	        let reply = client.join().unwrap();
136	        assert_eq!(reply.kind, "pong");
137	    }
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
140	    // is refused at the handshake, over the real socket.
141	    #[test]
142	    fn local_socket_rejects_wrong_role() {
143	        let name = unique_name();
144	        let broker = LocalSocketTransport::bind(&name).unwrap();
145
146	        let client_name = name.clone();
147	        let client = std::thread::spawn(move || {
148	            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
149	            // Announce as Broker on a connection where the broker expects a Brain.
150	            send_hello(&mut conn, Role::Broker).unwrap();
151	        });
152
153	        let mut server = broker.accept().unwrap();
154	        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
155	        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
156	        client.join().unwrap();
157	    }
158	}
159

//! The OS-split local-IPC carrier: named pipe (Windows) / Unix-domain socket
//! (unix), behind one [`DaemonTransport`] trait.
//!
//! This mirrors how `spt-term`'s `SessionSurface` abstracts ConPTY vs forkpty:
//! the broker and brain talk through one trait, and the platform split lives in
//! a single implementation ([`LocalSocketTransport`], over the `interprocess`
//! crate's local sockets — namespaced names work on both Windows and Linux).
//! The byte framing ([`crate::codec`]) and the version handshake
//! ([`crate::frame`]) ride on top, transport-agnostic.
// [impl->REQ-DAEMON-2]

use std::io::{self, Read, Write};

use interprocess::local_socket::{
    prelude::*, GenericNamespaced, ListenerOptions, Stream,
};

use crate::codec::{read_frame, write_frame};
use crate::frame::{accept_hello, Envelope, HandshakeError, Hello, Role};

/// A bidirectional IPC connection between the broker and a brain. Anything that
/// is both [`Read`] and [`Write`] qualifies, so tests can substitute an
/// in-memory or socket-pair duplex for the real local socket.
pub trait IpcConn: Read + Write {}
impl<T: Read + Write> IpcConn for T {}

/// A listening IPC endpoint plus its dialer. The broker `bind`s one and
/// `accept`s brains; a brain `connect`s. Abstracted as a trait so B1/B2 can
/// drive the broker over a stub transport in tests without a real socket.
pub trait DaemonTransport {
    /// The accepted/connected connection type.
    type Conn: IpcConn;

    /// Accept the next inbound connection (broker side). Blocks.
    fn accept(&self) -> io::Result<Self::Conn>;
}

/// The production transport: `interprocess` local sockets. A namespaced name
/// resolves to a Windows named pipe or a Linux abstract-namespace UDS — the one
/// OS split, contained here.
pub struct LocalSocketTransport {
    listener: interprocess::local_socket::Listener,
}

impl LocalSocketTransport {
    /// Bind a listening endpoint under the namespaced `name` (broker side).
    pub fn bind(name: &str) -> io::Result<Self> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        let listener = ListenerOptions::new().name(ns).create_sync()?;
        Ok(LocalSocketTransport { listener })
    }

    /// Connect to an existing endpoint under `name` (brain side).
    pub fn connect(name: &str) -> io::Result<Stream> {
        let ns = name.to_ns_name::<GenericNamespaced>()?;
        Stream::connect(ns)
    }
}

impl DaemonTransport for LocalSocketTransport {
    type Conn = Stream;

    fn accept(&self) -> io::Result<Self::Conn> {
        self.listener.accept()
    }
}

/// Brain side of the connect handshake: announce ourselves as `role` by sending
/// a `hello` frame. The broker answers with [`recv_hello`] + [`accept_hello`].
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn send_hello<W: Write>(w: &mut W, role: Role) -> io::Result<()> {
    let hello = Hello::current(role);
    let payload =
        serde_json::to_value(hello).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    write_frame(w, &Envelope::new("hello", payload))
}

/// Broker side: read the peer's first frame, require it to be a `hello`, and
/// run the version-tolerance policy ([`accept_hello`]) against `expected_peer`.
/// An incompatible peer surfaces a typed [`HandshakeError`] (never a silent
/// misparse — the 2.3 failure mode is a peer dying unlogged on a mismatch).
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn recv_hello<R: Read>(r: &mut R, expected_peer: Role) -> io::Result<Hello> {
    let env = read_frame(r)?;
    if env.kind != "hello" {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("expected hello frame, got kind {:?}", env.kind),
        ));
    }
    let hello: Hello = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    accept_hello(&hello, expected_peer)
        .map_err(|e: HandshakeError| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(hello)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::atomic::{AtomicU32, Ordering};

    // Unique namespaced socket name per test, so parallel test threads on one
    // machine never collide on the same pipe/UDS name.
    static SEQ: AtomicU32 = AtomicU32::new(0);
    fn unique_name() -> String {
        let n = SEQ.fetch_add(1, Ordering::Relaxed);
        format!("spt-daemon-test-{}-{}.sock", std::process::id(), n)
    }

    // [unit->REQ-DAEMON-2] a real local socket carries a hello handshake then a
    // frame, end to end, on whatever OS the test runs on (named pipe / UDS).
    #[test]
    fn local_socket_handshake_and_frame_round_trip() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            send_hello(&mut conn, Role::Brain).unwrap();
            write_frame(&mut conn, &Envelope::new("ping", json!({ "seq": 1 }))).unwrap();
            // Read the broker's reply.
            read_frame(&mut conn).unwrap()
        });

        let mut server = broker.accept().unwrap();
        let hello = recv_hello(&mut server, Role::Brain).unwrap();
        assert_eq!(hello.role, Role::Brain);
        let ping = read_frame(&mut server).unwrap();
        assert_eq!(ping.kind, "ping");
        write_frame(&mut server, &Envelope::new("pong", json!({ "seq": 1 }))).unwrap();

        let reply = client.join().unwrap();
        assert_eq!(reply.kind, "pong");
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] a brain announcing the wrong role
    // is refused at the handshake, over the real socket.
    #[test]
    fn local_socket_rejects_wrong_role() {
        let name = unique_name();
        let broker = LocalSocketTransport::bind(&name).unwrap();

        let client_name = name.clone();
        let client = std::thread::spawn(move || {
            let mut conn = LocalSocketTransport::connect(&client_name).unwrap();
            // Announce as Broker on a connection where the broker expects a Brain.
            send_hello(&mut conn, Role::Broker).unwrap();
        });

        let mut server = broker.accept().unwrap();
        let err = recv_hello(&mut server, Role::Brain).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
        client.join().unwrap();
    }
}


Unit `transport` tests passed (26) but integration `broker.rs` bind failed — so `Broker::bind` differs from `LocalSocketTransport::bind`. Read broker.rs test + Broker::bind + endpoint.rs.


1	//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
2	//! and injected input over the versioned local IPC, on both OSes.
3	//!
4	//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
5	//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
6	//! round-trip through the real PTY and come back as `output` events. We then
7	//! kill it and observe the `exit` event. This exercises the whole B1 contract —
8	//! spawn under a broker PTY, stream output, accept input, report exit — through
9	//! the actual named-pipe / UDS transport.
10	//!
11	//! The brain is driven **single-threaded**: send a command, then read frames
12	//! until the expected reply/event arrives (accumulating any interleaved output).
13	//! interprocess `Stream::split()` shares the underlying fd, so a split client
14	//! would only signal EOF once *both* halves drop; keeping one `Stream` means
15	//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
16	//! the session down deterministically — which we then assert.
17	// [int->REQ-DAEMON-2]
18
19	use std::sync::atomic::{AtomicU32, Ordering};
20	use std::sync::Arc;
21	use std::thread;
22	use std::time::Duration;
23
24	use interprocess::local_socket::Stream;
25	use spt_daemon::codec::{read_frame, write_frame};
26	use spt_daemon::frame::{Envelope, Role};
27	use spt_daemon::msg::{
28	    decode_bytes, encode_bytes, ExitEvent, InputReq, KillReq, OutputEvent, SpawnReq, Spawned,
29	    KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
30	};
31	use spt_daemon::transport::{send_hello, LocalSocketTransport};
32	use spt_daemon::Broker;
33
34	static SEQ: AtomicU32 = AtomicU32::new(0);
35	fn unique_name() -> String {
36	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
37	    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
38	}
39
40	/// The echo child: reads stdin, writes each line to stdout.
41	fn echo_spawn_req() -> SpawnReq {
42	    #[cfg(unix)]
43	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
44	    #[cfg(windows)]
45	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
46	    SpawnReq { program, args, rows: 24, cols: 80 }
47	}
48
49	fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
50	    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
51	}
52
53	fn main_test() {
54	    let name = unique_name();
55	    let broker = Broker::bind(&name).expect("bind broker");
56
57	    // The broker accepts one brain and serves it on a handler thread.
58	    let server_broker = Arc::clone(&broker);
59	    let server = thread::spawn(move || {
60	        let handle = server_broker.serve_once().expect("accept brain");
61	        handle.join().expect("handler thread");
62	    });
63
64	    // Brain side: connect (retry briefly in case accept lags the bind).
65	    let mut conn = None;
66	    for _ in 0..200 {
67	        match LocalSocketTransport::connect(&name) {
68	            Ok(c) => {
69	                conn = Some(c);
70	                break;

//! B1 integration: a [`Broker`] hosts one real PTY child and relays its output
//! and injected input over the versioned local IPC, on both OSes.
//!
//! The child is an echo program (`cat` on Unix, `findstr "."` on Windows — the
//! same cross-OS shape spt-term's injection tests use): bytes the brain injects
//! round-trip through the real PTY and come back as `output` events. We then
//! kill it and observe the `exit` event. This exercises the whole B1 contract —
//! spawn under a broker PTY, stream output, accept input, report exit — through
//! the actual named-pipe / UDS transport.
//!
//! The brain is driven **single-threaded**: send a command, then read frames
//! until the expected reply/event arrives (accumulating any interleaved output).
//! interprocess `Stream::split()` shares the underlying fd, so a split client
//! would only signal EOF once *both* halves drop; keeping one `Stream` means
//! dropping it closes the fd, the broker's command loop hits EOF, and it tears
//! the session down deterministically — which we then assert.
// [int->REQ-DAEMON-2]

use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use interprocess::local_socket::Stream;
use spt_daemon::codec::{read_frame, write_frame};
use spt_daemon::frame::{Envelope, Role};
use spt_daemon::msg::{
    decode_bytes, encode_bytes, ExitEvent, InputReq, KillReq, OutputEvent, SpawnReq, Spawned,
    KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_OUTPUT, KIND_SPAWN, KIND_SPAWNED,
};
use spt_daemon::transport::{send_hello, LocalSocketTransport};
use spt_daemon::Broker;

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-b1-{}-{}.sock", std::process::id(), n)
}

/// The echo child: reads stdin, writes each line to stdout.
fn echo_spawn_req() -> SpawnReq {
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    SpawnReq { program, args, rows: 24, cols: 80 }
}

fn send(conn: &mut Stream, kind: &str, payload: serde_json::Value) {
    write_frame(conn, &Envelope::new(kind, payload)).expect("send frame");
}

fn main_test() {
    let name = unique_name();
    let broker = Broker::bind(&name).expect("bind broker");

    // The broker accepts one brain and serves it on a handler thread.
    let server_broker = Arc::clone(&broker);
    let server = thread::spawn(move || {
        let handle = server_broker.serve_once().expect("accept brain");
        handle.join().expect("handler thread");
    });

    // Brain side: connect (retry briefly in case accept lags the bind).
    let mut conn = None;
    for _ in 0..200 {
        match LocalSocketTransport::connect(&name) {
            Ok(c) => {
                conn = Some(c);
                break;


cd C:/Users/decid/Documents/projects/spt-core && sed -n '1,80p' crates/spt-daemon/src/broker.rs | grep -nE 'fn bind|to_ns_name|socket|endpoint|SPT_HOME|broker_socket|Listener|ListenerOptions|create_sync|name'

Grep Broker::bind internals


6://! client / listening sockets. It runs **no logic** — no routing, registry,
40:use interprocess::local_socket::{prelude::*, SendHalf, Stream};


160:    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
161-        Self::bind_in(name, default_journal_path())
162-    }
163-
164-    /// Bind with an explicit effect-journal path — the testable seam (tests point
165-    /// the broker-owned anchor at a temp file rather than the shared home).
166:    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
167-        Ok(Arc::new(Broker {
168-            listener: LocalSocketTransport::bind(name)?,
169-            sessions: Arc::new(Mutex::new(HashMap::new())),
170-            next_id: AtomicU64::new(1),
171-            log_cap_chunks: DEFAULT_LOG_CHUNKS,
172-            journal: Arc::new(EffectJournal::open(journal_path)?),
173-        }))
174-    }
175-
176-    /// The broker-owned exactly-once anchor (for introspection / tests).
177-    pub fn journal(&self) -> &Arc<EffectJournal> {
178-        &self.journal


`Broker::bind` → `EffectJournal::open(default_journal_path())`. That's the bind failure, not the socket. The journal path's parent dir doesn't exist in CI. Check default_journal_path + EffectJournal::open.


cd C:/Users/decid/Documents/projects/spt-core && grep -rnE 'fn default_journal_path|fn open' crates/spt-daemon/src/broker.rs crates/spt-daemon/src/effect.rs

Locate default_journal_path + EffectJournal::open


crates/spt-daemon/src/broker.rs:418:fn default_journal_path() -> std::path::PathBuf {
crates/spt-daemon/src/effect.rs:119:    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {


414	    }
415	}
416
417	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
418	fn default_journal_path() -> std::path::PathBuf {
419	    spt_store::perch::spt_home().join("daemon-effects.log")
420	}
421
422	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
423	fn send_frame(send: &SharedSend, frame: &Envelope) {
424	    if let Ok(mut s) = send.lock() {
425	        let _ = write_frame(&mut *s, frame);
426	    }
427	}
428
429	/// Send an `error` event to the brain (best-effort).
430	fn send_error(send: &SharedSend, message: &str) {
431	    let frame = Envelope::new(
432	        KIND_ERROR,
433	        serde_json::to_value(ErrorEvent { message: message.to_string() })

    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(
        KIND_ERROR,
        serde_json::to_value(ErrorEvent { message: message.to_string() })


115	impl EffectJournal {
116	    /// Open (creating if absent) the journal at `path`, rebuilding the applied-set
117	    /// from any existing `PENDING`/`DONE` history. This recovery read is what
118	    /// makes the anchor survive a broker restart (constraint 1).
119	    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
120	        let path = path.into();
121	        let (applied, pending) = recover(&path)?;
122	        let file = OpenOptions::new().create(true).append(true).open(&path)?;
123	        Ok(EffectJournal { path, inner: Mutex::new(Inner { file, applied, pending }) })
124	    }
125
126	    /// The journal's backing path.
127	    pub fn path(&self) -> &Path {
128	        &self.path
129	    }
130
131	    /// Run `effect` exactly once for `key`. If `key` is already applied, the
132	    /// closure is **not** run and [`Outcome::Deduped`] is returned (the no-dup
133	    /// guard). Otherwise a `PENDING` line is fsync'd, the closure runs, and on
134	    /// success a `DONE` line is fsync'd and `key` joins the applied-set
135	    /// ([`Outcome::Applied`]). On closure error the `PENDING` record is left in
136	    /// place (the effect did not complete) and the error is propagated.
137	    ///
138	    /// The whole sequence holds the inner lock, so for any one broker process it
139	    /// is atomic against a concurrent re-drive — the property that makes a brain
140	    /// crash unable to open a duplicate window (see the module docs).
141	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
142	    pub fn apply_once<F>(&self, key: EffectKey, kind: EffectKind, effect: F) -> io::Result<Outcome>
143	    where
144	        F: FnOnce() -> io::Result<()>,
145	    {
146	        let mut g = self.inner.lock().expect("effect journal lock poisoned");
147	        if g.applied.contains(&key) {
148	            return Ok(Outcome::Deduped); // dedup-at-effect: already landed, do nothing
149	        }

impl EffectJournal {
    /// Open (creating if absent) the journal at `path`, rebuilding the applied-set
    /// from any existing `PENDING`/`DONE` history. This recovery read is what
    /// makes the anchor survive a broker restart (constraint 1).
    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
        let path = path.into();
        let (applied, pending) = recover(&path)?;
        let file = OpenOptions::new().create(true).append(true).open(&path)?;
        Ok(EffectJournal { path, inner: Mutex::new(Inner { file, applied, pending }) })
    }

    /// The journal's backing path.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Run `effect` exactly once for `key`. If `key` is already applied, the
    /// closure is **not** run and [`Outcome::Deduped`] is returned (the no-dup
    /// guard). Otherwise a `PENDING` line is fsync'd, the closure runs, and on
    /// success a `DONE` line is fsync'd and `key` joins the applied-set
    /// ([`Outcome::Applied`]). On closure error the `PENDING` record is left in
    /// place (the effect did not complete) and the error is propagated.
    ///
    /// The whole sequence holds the inner lock, so for any one broker process it
    /// is atomic against a concurrent re-drive — the property that makes a brain
    /// crash unable to open a duplicate window (see the module docs).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    pub fn apply_once<F>(&self, key: EffectKey, kind: EffectKind, effect: F) -> io::Result<Outcome>
    where
        F: FnOnce() -> io::Result<()>,
    {
        let mut g = self.inner.lock().expect("effect journal lock poisoned");
        if g.applied.contains(&key) {
            return Ok(Outcome::Deduped); // dedup-at-effect: already landed, do nothing
        }


Root cause confirmed: `EffectJournal::open` uses `create(true)` which creates the file but **not** parent dirs. `default_journal_path()` = `spt_home()/daemon-effects.log`; in fresh CI `spt_home()` dir doesn't exist → bind fails. Latent since B5 (broker.rs/handoff.rs set no SPT_HOME). Local passes only because home pre-exists. Fix: create parent dir in `open`.


Edit

    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
        let path = path.into();
        let (applied, pending) = recover(&path)?;
        let file = OpenOptions::new().create(true).append(true).open(&path)?;

    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
        let path = path.into();
        // Ensure the parent dir exists: the journal lives under `spt_home()`
        // (`default_journal_path`), which may not have been created yet on a fresh
        // machine — `OpenOptions::create` makes the file, never its directory, so a
        // missing parent surfaces as a NotFound at bind (the broker-bind CI failure).
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)?;
            }
        }
        let (applied, pending) = recover(&path)?;
        let file = OpenOptions::new().create(true).append(true).open(&path)?;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\effect.rs has been updated successfully. (file state is current in your context — no need to Read it back)

    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
        let path = path.into();
        let (applied, pending) = recover(&path)?;
        let file = OpenOptions::new().create(true).append(true).open(&path)?;

    pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
        let path = path.into();
        // Ensure the parent dir exists: the journal lives under `spt_home()`
        // (`default_journal_path`), which may not have been created yet on a fresh
        // machine — `OpenOptions::create` makes the file, never its directory, so a
        // missing parent surfaces as a NotFound at bind (the broker-bind CI failure).
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)?;
            }
        }
        let (applied, pending) = recover(&path)?;
        let file = OpenOptions::new().create(true).append(true).open(&path)?;

/// makes the anchor survive a broker restart (constraint 1).

+        // Ensure the parent dir exists: the journal lives under `spt_home()`

+        // (`default_journal_path`), which may not have been created yet on a fresh

+        // machine — `OpenOptions::create` makes the file, never its directory, so a

+        // missing parent surfaces as a NotFound at bind (the broker-bind CI failure).

+        if let Some(parent) = path.parent() {

+            if !parent.as_os_str().is_empty() {

+                std::fs::create_dir_all(parent)?;

Ok(EffectJournal { path, inner: Mutex::new(Inner { file, applied, pending }) })


Add regression test. Find effect.rs test module.


Bash

cd C:/Users/decid/Documents/projects/spt-core && grep -nE '#\[cfg\(test\)\]|mod tests|fn open_|use ' crates/spt-daemon/src/effect.rs | tail -20

Find effect.rs test module


34://! one that would cause a duplicate on replay — **cannot occur for a brain
48:use std::collections::HashSet;
49:use std::fs::{File, OpenOptions};
50:use std::io::{self, BufRead, BufReader, Write};
51:use std::path::{Path, PathBuf};
52:use std::sync::Mutex;
245:#[cfg(test)]
246:mod tests {
247:    use super::*;
248:    use std::cell::RefCell;


245	#[cfg(test)]
246	mod tests {
247	    use super::*;
248	    use std::cell::RefCell;
249
250	    fn jpath(dir: &tempfile::TempDir) -> PathBuf {
251	        dir.path().join("effects.log")
252	    }
253
254	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the no-dup guard: a second apply_once
255	    // for the same key never runs the effect again.
256	    #[test]
257	    fn second_apply_for_same_key_is_deduped() {
258	        let dir = tempfile::tempdir().unwrap();
259	        let j = EffectJournal::open(jpath(&dir)).unwrap();
260	        let runs = RefCell::new(0u32);
261	        let key = (1, 7);
262
263	        let o1 = j.apply_once(key, EffectKind::PtyWrite, || { *runs.borrow_mut() += 1; Ok(()) }).unwrap();
264	        let o2 = j.apply_once(key, EffectKind::PtyWrite, || { *runs.borrow_mut() += 1; Ok(()) }).unwrap();
265
266	        assert_eq!(o1, Outcome::Applied);
267	        assert_eq!(o2, Outcome::Deduped, "the replay must be deduped, not re-run");
268	        assert_eq!(*runs.borrow(), 1, "the effect ran exactly once");
269	        assert!(j.is_applied(key));
270	    }
271
272	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] a closure error leaves the key
273	    // un-applied (no false DONE) so a later re-drive re-attempts it — no drop.
274	    #[test]
275	    fn failed_effect_is_not_recorded_applied() {
276	        let dir = tempfile::tempdir().unwrap();
277	        let j = EffectJournal::open(jpath(&dir)).unwrap();
278	        let key = (1, 1);
279
280	        let err = j.apply_once(key, EffectKind::Spool, || Err(io::Error::other("boom")));
281	        assert!(err.is_err());
282	        assert!(!j.is_applied(key), "a failed effect must not count as applied");
283
284	        // Re-drive succeeds and applies exactly once.

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    fn jpath(dir: &tempfile::TempDir) -> PathBuf {
        dir.path().join("effects.log")
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the no-dup guard: a second apply_once
    // for the same key never runs the effect again.
    #[test]
    fn second_apply_for_same_key_is_deduped() {
        let dir = tempfile::tempdir().unwrap();
        let j = EffectJournal::open(jpath(&dir)).unwrap();
        let runs = RefCell::new(0u32);
        let key = (1, 7);

        let o1 = j.apply_once(key, EffectKind::PtyWrite, || { *runs.borrow_mut() += 1; Ok(()) }).unwrap();
        let o2 = j.apply_once(key, EffectKind::PtyWrite, || { *runs.borrow_mut() += 1; Ok(()) }).unwrap();

        assert_eq!(o1, Outcome::Applied);
        assert_eq!(o2, Outcome::Deduped, "the replay must be deduped, not re-run");
        assert_eq!(*runs.borrow(), 1, "the effect ran exactly once");
        assert!(j.is_applied(key));
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] a closure error leaves the key
    // un-applied (no false DONE) so a later re-drive re-attempts it — no drop.
    #[test]
    fn failed_effect_is_not_recorded_applied() {
        let dir = tempfile::tempdir().unwrap();
        let j = EffectJournal::open(jpath(&dir)).unwrap();
        let key = (1, 1);

        let err = j.apply_once(key, EffectKind::Spool, || Err(io::Error::other("boom")));
        assert!(err.is_err());
        assert!(!j.is_applied(key), "a failed effect must not count as applied");

        // Re-drive succeeds and applies exactly once.


Edit

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the no-dup guard: a second apply_once
    // for the same key never runs the effect again.
    #[test]
    fn second_apply_for_same_key_is_deduped() {

    // [unit->REQ-DAEMON-2] opening a journal whose parent dir does not exist yet
    // creates the dir rather than failing at bind — `default_journal_path` lives
    // under `spt_home()`, absent on a fresh machine (the broker-bind CI failure:
    // `OpenOptions::create` makes the file, never its directory).
    #[test]
    fn open_creates_missing_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let nested = dir.path().join("does/not/exist/yet");
        assert!(!nested.exists(), "precondition: parent dir is absent");
        let j = EffectJournal::open(nested.join("daemon-effects.log")).unwrap();
        assert!(nested.exists(), "open created the missing parent dir");
        // The journal is usable straight away.
        assert_eq!(
            j.apply_once((1, 1), EffectKind::PtyWrite, || Ok(())).unwrap(),
            Outcome::Applied
        );
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the no-dup guard: a second apply_once
    // for the same key never runs the effect again.
    #[test]
    fn second_apply_for_same_key_is_deduped() {


    // [unit->REQ-DAEMON-2] opening a journal whose parent dir does not exist yet
    // creates the dir rather than failing at bind — `default_journal_path` lives
    // under `spt_home()`, absent on a fresh machine (the broker-bind CI failure:
    // `OpenOptions::create` makes the file, never its directory).
    #[test]
    fn open_creates_missing_parent_dirs() {
        let dir = tempfile::tempdir().unwrap();
        let nested = dir.path().join("does/not/exist/yet");
        assert!(!nested.exists(), "precondition: parent dir is absent");
        let j = EffectJournal::open(nested.join("daemon-effects.log")).unwrap();
        assert!(nested.exists(), "open created the missing parent dir");
        // The journal is usable straight away.
        assert_eq!(
            j.apply_once((1, 1), EffectKind::PtyWrite, || Ok(())).unwrap(),
            Outcome::Applied
        );
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the no-dup guard: a second apply_once
    // for the same key never runs the effect again.
    #[test]
    fn second_apply_for_same_key_is_deduped() {

+    // [unit->REQ-DAEMON-2] opening a journal whose parent dir does not exist yet

+    // creates the dir rather than failing at bind — `default_journal_path` lives

+    // under `spt_home()`, absent on a fresh machine (the broker-bind CI failure:

+    // `OpenOptions::create` makes the file, never its directory).

+    fn open_creates_missing_parent_dirs() {

+        let dir = tempfile::tempdir().unwrap();

+        let nested = dir.path().join("does/not/exist/yet");

+        assert!(!nested.exists(), "precondition: parent dir is absent");

+        let j = EffectJournal::open(nested.join("daemon-effects.log")).unwrap();

+        assert!(nested.exists(), "open created the missing parent dir");

+        // The journal is usable straight away.

+            j.apply_once((1, 1), EffectKind::PtyWrite, || Ok(())).unwrap(),


Verify: run effect test + simulate CI's missing-home by pointing SPT_HOME at a non-existent dir for broker/handoff tests.


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-daemon effect::tests::open_creates_missing_parent_dirs 2>&1 | tail -5

Run new effect regression test


cd C:/Users/decid/Documents/projects/spt-core && SPT_HOME="$(pwd)/target/ci-sim-home-nonexistent" cargo test -p spt-daemon --test broker --test handoff 2>&1 | tail -8

Run broker+handoff with non-existent SPT_HOME (CI sim)


running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s


     Running tests\handoff.rs (target\debug\deps\handoff-d5af4760b419c067.exe)

running 1 test
test brain_restart_survives_gaplessly_with_fresh_gen_start ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s


cd C:/Users/decid/Documents/projects/spt-core && rm -rf target/ci-sim-home2 && SPT_HOME="$(pwd)/target/ci-sim-home2/deep/missing" cargo test -p spt-daemon --test broker 2>&1 | tail -6 && echo "===EFFECT LIB===" && cargo test -p spt-daemon --lib effect:: 2>&1 | tail -6

Run broker under deep-missing SPT_HOME + effect lib tests


running 1 test
test broker_hosts_pty_child_streams_output_and_accepts_input ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

===EFFECT LIB===
test effect::tests::applied_set_survives_reopen ... ok
test effect::tests::failed_effect_is_not_recorded_applied ... ok
test effect::tests::gauntlet_is_exactly_once_across_brain_crashes ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 44 filtered out; finished in 0.11s


Fix verified — broker passes under missing SPT_HOME (the CI failure). Run clippy + check, then commit + push.


cd C:/Users/decid/Documents/projects/spt-core && cargo clippy -p spt-daemon --all-targets -- -D warnings 2>&1 | tail -4 && echo "===CHECK===" && traceable-reqs check >/dev/null 2>&1 && echo "CHECK OK" || echo "CHECK FAIL"

Clippy daemon + traceability check


    Checking spt-daemon v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.65s
===CHECK===
CHECK OK


cd C:/Users/decid/Documents/projects/spt-core && git add -A && git commit -q -F - <<'EOF'
fix(daemon): EffectJournal::open creates missing parent dir (broker-bind CI failure)

`default_journal_path()` lives under `spt_home()`, which may not exist on a
fresh machine. `OpenOptions::create` makes the file but never its directory, so
`Broker::bind` failed at `EffectJournal::open` with NotFound ("cannot find the
path specified") on any CI runner without a pre-existing spt home — red since
B5 introduced the journal, masked locally where the home already exists.

`open` now `create_dir_all`s the parent before opening. Regression test:
opening a journal under a non-existent nested dir creates the dir and yields a
usable journal. Verified: `broker`/`handoff` integration tests pass with
SPT_HOME pointed at a deep non-existent path (the CI scenario).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git push origin dev-freeform 2>&1 | tail -3

Commit fix and push


warning: in the working copy of 'crates/spt-daemon/src/effect.rs', LF will be replaced by CRLF the next time Git touches it
To https://github.com/SaberMage/spt-core.git
   20817a6..2573101  dev-freeform -> dev-freeform


Pushed `2573101`. Prior B9 run (20817a6) would've failed on broker/handoff. New run on the fix should go green. Check it registered.


cd C:/Users/decid/Documents/projects/spt-core && sleep 15 && gh run list --branch dev-freeform --limit 2 --json databaseId,headSha,status,conclusion 2>&1

Confirm new CI run on fix commit


Confirmed: 20817a6 (no fix) **failed** as predicted; 2573101 (fix) in_progress. Watch it to completion in background.


cd C:/Users/decid/Documents/projects/spt-core && gh run watch 26898758868 --exit-status > target/ci-watch.log 2>&1; echo "EXIT=$?"; gh run view 26898758868 --json conclusion,jobs --jq '{conclusion, jobs: [.jobs[] | {name, conclusion}]}'

Watch CI run to completion (background)


Command running in background with ID: briyb0w1l. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\briyb0w1l.output. You will be notified when it completes. To check interim output, use Read on that file path.


CI watch running in background. Status while we wait:

**Pushed + found+fixed a latent CI bug:**
- `20817a6` (B9) pushed → CI **failed** (predicted): `Broker::bind` → `EffectJournal::open` hit NotFound on fresh CI runners — `spt_home()` dir absent, `OpenOptions::create` makes the file not its dir. Red since B5; masked locally.
- `2573101` (fix) pushed → `EffectJournal::open` now `create_dir_all`s parent + regression test. Verified locally: `broker`/`handoff` pass under deep non-existent `SPT_HOME` (the CI scenario). Clippy clean, check green.

CI run `26898758868` (matrix: traceability + ubuntu + windows) in_progress. Will report when it lands.


TaskUpdate

cargo test --workspace, clippy -D warnings, traceable-reqs check green locally. CI matrix (ubuntu+windows) via push — fixed latent EffectJournal parent-dir bind bug (2573101); watching run 26898758868.


Task not found


Waiting on CI. Will report the matrix result when the watch completes.


estimate the remaining time and schedule a wakeup for yourself to draw a line and troubleshoot if it hasn't concluded by then


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


Prior runs: 2–9 min (worst 8m56s). Green run compiles full + both-OS suites; windows slower. Set 10-min ceiling.


ScheduleWakeup

CI matrix historically peaks ~9min; 10-min line to troubleshoot run 26898758868 if the background watch hasn't reported green.

Prompt: Check CI run 26898758868 (gh run view --json conclusion,jobs). If concluded green: report + done. If failed: pull --log-failed, diagnose, fix, repush. If still in_progress past this line: investigate why it's hung (gh run view, check for stuck job/runner).


Next wakeup scheduled for 09:48:00 (in 624s). Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.


Wakeup set for **09:48** (10-min line). Background watch on run `26898758868` will notify me sooner if it lands first. Either way I'll report the matrix result — or troubleshoot if it's failed/hung by then.


<task-notification>
<task-id>briyb0w1l</task-id>
<tool-use-id>toolu_01AN2rYgKyv5PD5FHTbhV1DR</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\b494f5e6-5b4d-4145-aaaa-5585e1168377\tasks\briyb0w1l.output</output-file>
<status>completed</status>
<summary>Background command "Watch CI run to completion (background)" completed (exit code 0)</summary>
</task-notification>


Watch exited 0 = green. Confirm.


Bash

cd C:/Users/decid/Documents/projects/spt-core && cat target/ci-watch.log | tail -3; echo "==="; gh run view 26898758868 --json conclusion,jobs --jq '{conclusion, jobs: [.jobs[] | {name, conclusion}]}'

Confirm final CI conclusion + jobs


- NOTICE: windows-latest requests are being redirected to windows-2025-vs2026 by June 15, 2026
test (windows-latest): .github#1

===
{"conclusion":"success","jobs":[{"conclusion":"success","name":"test (ubuntu-latest)"},{"conclusion":"success","name":"traceability"},{"conclusion":"success","name":"test (windows-latest)"}]}


**CI matrix GREEN** — all three jobs success:
- `traceability` ✅
- `test (ubuntu-latest)` ✅
- `test (windows-latest)` ✅

Run `26898758868` (commit `2573101`). B9 acceptance fully satisfied: CI matrix (ubuntu+windows) green, both-OS validated.

**M3b COMPLETE + shipped.** Summary of this session:
- B9 daemon E2E (spawn→Psyche-loop→commune→brain-restart-survives→graceful-signoff) — int evidence for DAEMON-1/2/4 + DAEMON-HOSTED-LIVENESS + RESTART-IDEMPOTENT, all `[impl,unit,int]`.
- Caught + fixed a latent CI bug: `EffectJournal::open` not creating its parent dir (red since B5, masked locally) → `create_dir_all` + regression test.
- ROADMAP + CONTEXT: M3b delivered (2026-06-03); M3c-PLAN.md stub authored.
- Pushed `dev-freeform` → `2573101`.

Commune the green + fix delta to Psyche:


Write

# Commune — M3b SHIPPED, CI matrix green

**B9 + CI green.** M3b milestone complete AND validated on the CI matrix (ubuntu + windows + traceability all ✅). Run 26898758868, commit `2573101` on `dev-freeform` (pushed).

Delta since last commune:
- Pushed B7/B8/B9 (origin was behind). First B9 run (20817a6) **FAILED** — caught a latent bug: `Broker::bind`→`EffectJournal::open` hit NotFound on fresh CI runners (`spt_home()` dir absent; `OpenOptions::create` makes the file, never its dir). Red since B5, masked locally where home pre-exists.
- **Fix `2573101`:** `EffectJournal::open` now `create_dir_all`s parent + regression test (`open_creates_missing_parent_dirs`, tagged unit->REQ-DAEMON-2). Verified locally: broker/handoff pass under deep non-existent SPT_HOME. Re-pushed → CI fully green.

Gotcha worth remembering: **daemon integration tests that DON'T set SPT_HOME (broker.rs, handoff.rs) exercise the real `spt_home()` — which is absent on fresh CI.** Anything opening a file under spt_home() must create its parent dir. Tests that set SPT_HOME=tempdir (digest.rs, daemon_e2e.rs) masked this.

**Next: M3c** — signed self-update engine (M3c-PLAN.md stub ready). Then M3 closes; M4 = networking (real cross-node proof). M3 is now one milestone from done.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

# Commune — M3b SHIPPED, CI matrix green

**B9 + CI green.** M3b milestone complete AND validated on the CI matrix (ubuntu + windows + traceability all ✅). Run 26898758868, commit `2573101` on `dev-freeform` (pushed).

Delta since last commune:
- Pushed B7/B8/B9 (origin was behind). First B9 run (20817a6) **FAILED** — caught a latent bug: `Broker::bind`→`EffectJournal::open` hit NotFound on fresh CI runners (`spt_home()` dir absent; `OpenOptions::create` makes the file, never its dir). Red since B5, masked locally where home pre-exists.
- **Fix `2573101`:** `EffectJournal::open` now `create_dir_all`s parent + regression test (`open_creates_missing_parent_dirs`, tagged unit->REQ-DAEMON-2). Verified locally: broker/handoff pass under deep non-existent SPT_HOME. Re-pushed → CI fully green.

Gotcha worth remembering: **daemon integration tests that DON'T set SPT_HOME (broker.rs, handoff.rs) exercise the real `spt_home()` — which is absent on fresh CI.** Anything opening a file under spt_home() must create its parent dir. Tests that set SPT_HOME=tempdir (digest.rs, daemon_e2e.rs) masked this.

**Next: M3c** — signed self-update engine (M3c-PLAN.md stub ready). Then M3 closes; M4 = networking (real cross-node proof). M3 is now one milestone from done.


Communed. M3b shipped clean.

Note: 09:48 wakeup still scheduled — when it fires it'll see green and close out (no action needed).

**Done. M3b complete, CI matrix green, pushed. M3c is next.**


<command-message>spt:commune</command-message>
<command-name>/spt:commune</command-name>
<command-args>with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear</command-args>


Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.25\skills\commune

# /spt:commune

All commands use `$LIVE` env var, auto-injected by the plugin's SessionStart hook. If commands fail with "command not found", restart the Claude Code session so the SessionStart hook re-runs.

> **Identity auto-detection:** Your identity is auto-detected from your session. Pass your ID explicitly only if auto-detection fails.

## Flow

1. **Download Psyche's *current* context** — it shifts during the session as background echo communes fire. Re-download right before composing; the gen-N snapshot you read at session start is already stale:
   ```bash
   $LIVE psyche-download <your-id>
   ```

   > **Output truncation.** If the Bash tool reports `Output too large (N KB)` and saves the full result to a `tool-results/<id>.txt` file, use the `Read` tool on that saved path to access the full content. Do NOT re-run `psyche-download` to grep/awk out sections — repeated invocations re-download the same context, waste tokens, and may duplicate `## Pending Commune` sections in `live_context.md`.

2. **Diff your knowledge against this fresh download** — only what's missing from *it* is the real delta (new work, decisions, intentions). Anything already absorbed is not. If the download includes a `<memformat>` section, use it as a guide for what topics to cover. A `## Pending Commune (uncommitted)` section means a previous commune is still queued — do NOT re-send it.

3. **Commune only the delta** -- use the **Write tool** to create
   `.claude/{your-id}-commune.md` with the two-slice body as its contents
   (see `## Two-slice body shape (Phase 25 D-10/D-11)` below for the required
   envelope wrapping). Do not use Bash/heredoc -- Write is the canonical path:
   no shell escaping, no `EOF` collision risk, and Write auto-creates the
   `.claude/` parent directory.

   Your Self listener detects the file on its next poll iteration, notifies
   your Psyche wrapper, and the wrapper ingests the content via the existing
   Psyche session. On success (subprocess exit code 0) the wrapper deletes the
   file. On error (token rate limit, etc) the file is retained and retried on
   the next consume cycle. If you write again before the wrapper consumes, the
   latest write wins (last-write-wins overwrite semantics).

If Psyche's context is already current, no commune is needed. Say so and move on.

Read `commune.md` (in this skill directory) for the full commune protocol -- when to send, what to include, and diligence triggers.

**Agent-type guard:** Only works on live agents. If target is not live, refuses with: "Communes require a live agent with Psyche."

## Two-slice body shape (Phase 25 D-10/D-11)

The body you write in `.claude/{your-id}-commune.md` MUST itself be wrapped in two-slice envelopes per Phase 25 D-10/D-11. The taxonomy — what belongs in `<live-context>` vs `<project-context>` — is defined canonically in `psyche.md` §`<output_envelope>` (lines 282–323). Read that section as the source of truth; this skill doc teaches only when and how to apply it.

**In-project detection rule (D-25.1-04).** Before composing your commune body, run `$LIVE psyche-download <your-id>` (Step 1 of `## Flow` above) and inspect the `<current ... />` tag at the top of the output. The `<current/>` tag carries a `project="..."` attribute whenever any payload is produced — its presence means you are inside a tracked project (inside a git repo, `project` is the repo name; outside a git repo per D-11, `project` is the cwd-basename fallback, which still counts as a project context for routing purposes). A `psyche-download` invocation that returns no payload at all (NO-CONTEXT exit) means no project resolved. So: `project="..."` populated on `<current/>` → emit BOTH envelopes (apply rule 1 or 2 below); no payload at all → emit ONLY `<live-context>` (rule 3). Secondary signal: the `<project-context-resolved name="..."/>` sentinel further down the output indicates the project file already has prior content — useful for spotting first-time-in-project pairings, but NOT the routing rule.

**Three rules (D-25.1-01..03):**

- **In-project, both slices populated** (marker present, you have project-specific content this cycle): emit `<live-context>...</live-context>` AND `<project-context>...</project-context>`, both with non-empty bodies.
- **In-project, no project-specific update this cycle** (marker present, but nothing project-bound to report): emit `<live-context>...</live-context>` AND `<project-context></project-context>` (empty body — the deliberate "in-project but quiet" signal per D-25.1-02). Distinguishable from "no project resolved".
- **Outside any tracked project** (marker absent): emit ONLY `<live-context>...</live-context>`. Do NOT emit an empty `<project-context>` envelope (missing tag means "no project resolved" per D-25.1-03).

**Nested envelopes.** The Phase 23 `<EVENT type="commune">` envelope (see `## Envelope shape (Phase 23 v1.8)` below) is composed by `$LIVE commune` automatically — you NEVER write `<EVENT>` tags. You write ONLY the inner two-slice body. The two envelope layers nest: outer EVENT wraps inner two-slice body.

(See `psyche.md` §`<output_envelope>` for the canonical taxonomy and per-slice content rules.)

## Envelope shape (Phase 23 v1.8)

As of v1.8 Phase 23, commune deliveries are wrapped in a typed EVENT envelope carrying a 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`); the prose `COMMUNE (timestamp): body` form is gone.

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="claude_skill_owl" branch="main" head_sha="abc123..." head_subject="subject line capped at 72 chars">body</EVENT>
```

Outside a git repo (D-11), `branch`, `head_sha`, and `head_subject` are omitted entirely (not empty-string-set):

```xml
<EVENT type="commune" timestamp="2026-05-20T06:00:00-07:00" machine="hostname" project="cwd-basename">body</EVENT>
```

`$LIVE commune` composes this envelope automatically — Self does NOT manually construct EVENT XML. The `body` placeholder in the example above is the two-slice body (see `## Two-slice body shape (Phase 25 D-10/D-11)` above); the two envelope layers nest. The wrapper-side parser ignores unknown attrs, so the stamp fields are forward-compatible with older receivers. For drift detection on resume, see the `live` skill.

ARGUMENTS: with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear


/spt:commune with immediate next steps and broad summary of the project's status + end goal. we'll resume in the next session --> proceed with /self-clear


Download fresh Psyche context before composing.


Bash

cd C:/Users/decid/Documents/projects/spt-core && $LIVE psyche-download doyle 2>&1 | head -120

Download current Psyche context


<memformat>
<?xml version="1.0"?>
<memformat version="1">
<!-- Changelog: v1 - initial seed -->
<topics>
  <topic name="events">Notable or unusual events and requests</topic>
  <topic name="interactions">Agent interactions -- who, roles, contributions</topic>
  <topic name="reflections">Thoughts on recent work -- what went well, what was tricky</topic>
  <topic name="ideas">Ideas to raise with the user</topic>
  <topic name="improvements">Further improvement ideas for recent problem spaces</topic>
  <topic name="timeline">What happened when, timing, sequencing</topic>
  <topic name="blockers">Current blockers or concerns</topic>
  <topic name="focus">Current focus and immediate next steps</topic>
</topics>
</memformat>
</memformat>

<current machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="2573101b65c6a1b0e2d119f4277762f69b4638d8" head_subject="fix(daemon): EffectJournal::open creates missing parent dir (broker-bind…" commits_since="0" commits_unpulled="0"/>
# Commune — M3b SHIPPED, CI matrix green

**B9 + CI green.** M3b milestone complete AND validated on the CI matrix (ubuntu + windows + traceability all ✅). Run 26898758868, commit `2573101` on `dev-freeform` (pushed).

Delta since last commune:
- Pushed B7/B8/B9 (origin was behind). First B9 run (20817a6) **FAILED** — caught a latent bug: `Broker::bind`→`EffectJournal::open` hit NotFound on fresh CI runners (`spt_home()` dir absent; `OpenOptions::create` makes the file, never its dir). Red since B5, masked locally where home pre-exists.
- **Fix `2573101`:** `EffectJournal::open` now `create_dir_all`s parent + regression test (`open_creates_missing_parent_dirs`, tagged unit->REQ-DAEMON-2). Verified locally: broker/handoff pass under deep non-existent SPT_HOME. Re-pushed → CI fully green.

Gotcha worth remembering: **daemon integration tests that DON'T set SPT_HOME (broker.rs, handoff.rs) exercise the real `spt_home()` — which is absent on fresh CI.** Anything opening a file under spt_home() must create its parent dir. Tests that set SPT_HOME=tempdir (digest.rs, daemon_e2e.rs) masked this.

**Next: M3c** — signed self-update engine (M3c-PLAN.md stub ready). Then M3 closes; M4 = networking (real cross-node proof). M3 is now one milestone from done.<project-context-resolved name="spt-core"/>
B8 started + mid-impl. spt-store + spt-daemon + spt-term touch zones confirmed. No spawn_detached/gapless-handoff regressions observed. Contract boundary (spt-term digest types) seeded for cross-crate IPC serde. Path-B (history append) centralized in spt-store; both daemon + CLI call it. Tail-delta semantic shift (from index) is internal to DigestEngine; no API surface change yet pending hub tests.


Last commune (M3b-shipped) already ingested into `live_context`. But `project-context` still stale at "B8 mid-impl". Delta to send: session wrap/self-clear + resume pointer (live), and refreshed project state (M3b shipped, M3c next, end goal). In-project → both slices populated.


Write

<live-context>
**Session wrapping → /self-clear now; resuming next session.** Context being cleared deliberately, not a crash — listener + Psyche survive intact.

**Immediate next steps on resume (M3c — signed self-update):**
1. Read `M3c-PLAN.md` (stub authored this session) + ADR-0004 §A (update-class taxonomy) + §D (hardening).
2. Author the full M3c JIT task layer (expand the C0–C4 stub table), then start C0: brain-only zero-interruption update — swap the binary over the EXISTING M3b broker/brain handoff path (don't re-implement a handoff; M3b's gapless brain restart is the substrate). Prove E2E (REQ-UPD-3, absolute: no hosted endpoint interrupts).
3. Activate REQ-UPD-* reqs per task (currently all `required_stages = []`); `REQ-UPD-1` (P2P propagation) STAYS `[]` until M4.

**Standing discipline:** tag evidence (`impl`/`unit`/`int`) in the same commit; `traceable-reqs check` green + clippy `-D warnings` + CI matrix (ubuntu+windows) green before declaring done. Push triggers CI — only when user asks.

**Fresh CI gotcha (cost me a red run this session):** daemon integration tests that don't set SPT_HOME hit the real `spt_home()`, absent on fresh runners. Anything opening a file under spt_home() must `create_dir_all` its parent. Fixed in EffectJournal (`2573101`).
</live-context>
<project-context>
**M3b SHIPPED + CI-green (supersedes the stale "B8 mid-impl" state).** Commit `2573101` on `dev-freeform`, run 26898758868 green (ubuntu+windows+traceability). The broker/brain architecture is now REAL, not interim:

- `crates/spt-daemon` is the 7th crate (`…→spt-live→spt-daemon→spt`). Broker = stable kernel (PTY masters from spt-term, harness children, sockets; no logic). Brain = restartable logic (routing/registry/manifest + re-hosted M2b lifecycle). Versioned local IPC (named-pipe/UDS behind `DaemonTransport`, forward-compat frames) lets a newer brain talk to an older broker.
- Proven: brain kill/restart leaves hosted PTY child + output stream intact + gapless; `gen_start=now()` each handoff; exactly-once broker↔brain boundary (broker-owned EffectJournal + dedup-at-effect); daemon-authoritative liveness (info.json `status`, not pid); auto-start + in-memory seed (seed file dead); supervised-crash orphan-watch; digest daemon-half (REQ-TERM-4 int).
- All M3b reqs at `[impl,unit,int]`. Daemon E2E: `crates/spt-daemon/tests/daemon_e2e.rs`.

**Where M3 stands:** M3a ✅ (spt-term PTY mechanism) · M3b ✅ (spt-daemon) · **M3c = LAST M3 task** (self-update; likely no new crate — engine lives in spt-daemon + signing primitives in spt-store). Then M4 = `spt-net` (Iroh WAN + mDNS LAN + pairing + cross-node Psyche sync).

**End goal:** harness-independent agent-ecosystem core (messaging, live-agent lifecycle, terminal hosting, first-class P2P networking, runtime-manifest harness contract) as Rust crates + one `spt` binary. The proof = same agent runs natively across all a user's machines with a synced mind, reachable/operable from anywhere, seamless signed self-update. First thing built atop it: rebuilt `spt` Claude-Code plugin at parity with today's SPT (separate downstream project, not in this repo).
</project-context>


<live-context>
**Session wrapping → /self-clear now; resuming next session.** Context being cleared deliberately, not a crash — listener + Psyche survive intact.

**Immediate next steps on resume (M3c — signed self-update):**
1. Read `M3c-PLAN.md` (stub authored this session) + ADR-0004 §A (update-class taxonomy) + §D (hardening).
2. Author the full M3c JIT task layer (expand the C0–C4 stub table), then start C0: brain-only zero-interruption update — swap the binary over the EXISTING M3b broker/brain handoff path (don't re-implement a handoff; M3b's gapless brain restart is the substrate). Prove E2E (REQ-UPD-3, absolute: no hosted endpoint interrupts).
3. Activate REQ-UPD-* reqs per task (currently all `required_stages = []`); `REQ-UPD-1` (P2P propagation) STAYS `[]` until M4.

**Standing discipline:** tag evidence (`impl`/`unit`/`int`) in the same commit; `traceable-reqs check` green + clippy `-D warnings` + CI matrix (ubuntu+windows) green before declaring done. Push triggers CI — only when user asks.

**Fresh CI gotcha (cost me a red run this session):** daemon integration tests that don't set SPT_HOME hit the real `spt_home()`, absent on fresh runners. Anything opening a file under spt_home() must `create_dir_all` its parent. Fixed in EffectJournal (`2573101`).
</live-context>
<project-context>
**M3b SHIPPED + CI-green (supersedes the stale "B8 mid-impl" state).** Commit `2573101` on `dev-freeform`, run 26898758868 green (ubuntu+windows+traceability). The broker/brain architecture is now REAL, not interim:

- `crates/spt-daemon` is the 7th crate (`…→spt-live→spt-daemon→spt`). Broker = stable kernel (PTY masters from spt-term, harness children, sockets; no logic). Brain = restartable logic (routing/registry/manifest + re-hosted M2b lifecycle). Versioned local IPC (named-pipe/UDS behind `DaemonTransport`, forward-compat frames) lets a newer brain talk to an older broker.
- Proven: brain kill/restart leaves hosted PTY child + output stream intact + gapless; `gen_start=now()` each handoff; exactly-once broker↔brain boundary (broker-owned EffectJournal + dedup-at-effect); daemon-authoritative liveness (info.json `status`, not pid); auto-start + in-memory seed (seed file dead); supervised-crash orphan-watch; digest daemon-half (REQ-TERM-4 int).
- All M3b reqs at `[impl,unit,int]`. Daemon E2E: `crates/spt-daemon/tests/daemon_e2e.rs`.

**Where M3 stands:** M3a ✅ (spt-term PTY mechanism) · M3b ✅ (spt-daemon) · **M3c = LAST M3 task** (self-update; likely no new crate — engine lives in spt-daemon + signing primitives in spt-store). Then M4 = `spt-net` (Iroh WAN + mDNS LAN + pairing + cross-node Psyche sync).

**End goal:** harness-independent agent-ecosystem core (messaging, live-agent lifecycle, terminal hosting, first-class P2P networking, runtime-manifest harness contract) as Rust crates + one `spt` binary. The proof = same agent runs natively across all a user's machines with a synced mind, reachable/operable from anywhere, seamless signed self-update. First thing built atop it: rebuilt `spt` Claude-Code plugin at parity with today's SPT (separate downstream project, not in this repo).
</project-context>


Commune queued (M3b-shipped already ingested; this refreshes the stale project slice + resume pointer). Next-work JIT plan exists (`M3c-PLAN.md`). Proceeding to /self-clear.


Skill

Resume M3c (signed self-update) — LAST M3 task. Read M3c-PLAN.md + ADR-0004 §A/§D, expand the C0–C4 stub into a full JIT task layer, then start C0: brain-only zero-interruption update over the existing M3b broker/brain handoff path (REQ-UPD-3, prove E2E). Activate REQ-UPD-* per task; REQ-UPD-1 stays [] until M4. M3b is shipped + CI-green (commit 2573101). Discipline: tag evidence same-commit, traceable-reqs check + clippy -D warnings + CI matrix green before done; push only when user asks.


Launching skill: self-clear


Base directory for this skill: C:\Users\decid\.claude\skills\self-clear

# /self-clear

Wipes THIS agent's conversation (`/clear`), then ~10s later TYPES a self-note
as a fresh user prompt so the agent wakes on a clean context and proceeds.

## Run it

One script does everything. Just run:

```powershell
& "C:\Users\decid\.claude\skills\self-clear\scripts\self-clear.ps1"
```

Expect: `SELF-CLEAR armed: id=<agent_id> winpid=<n> hwnd=<n> — /clear queued; self-note typed in ~10s`

Then stop — end your turn promptly so the queued `/clear` fires.

## What the script does

1. `owl whoami` → resolves this session's `agent_id` (context-independent, survives the wipe). No perch → it aborts WITHOUT clearing and tells you to `/spt:ready` or `/spt:live` first.
2. Walks the parent chain (`pwsh → claude.exe → … → WindowsTerminal`) to the ancestor that owns the window, foregrounds it.
3. Types `/clear` + Enter — **queued**; only fires when this turn ends.
4. Spawns a **detached** waker: waits 10s (past turn-end + wipe), re-foregrounds the terminal via the arm-time `hwnd`/`winpid`, then **types** `NOTE FROM THE USER: proceed with next steps.` + Enter as a fresh user prompt.

The 10s delay + detachment are deliberate: the prompt must be typed **after**
`/clear` takes effect, so it lands on the clean context and starts a new turn.

Why typed, not `owl send`: after `/clear` the session sits idle awaiting user
input. A spooled owl message can't self-start a turn — it surfaces only during an
already-active turn (PreToolUse hook) or in a live Monitor stream (whose buffer
`/clear` wipes). So a self-addressed owl note never woke the agent. A typed prompt
deterministically starts a turn AND is the re-seed.

## Caveats

- Window focus brings the whole terminal forward; it can't target a specific tab. Clean when the agent's tab is active.
- The note is typed via SendKeys, so the terminal must stay the foreground-able window of `winpid`. Don't close/replace the terminal in the ~10s gap.
- SendKeys reserves `+ ^ % ~ ( ) { } [ ]`; the default note text uses none. If you customize the note, escape those chars.
- If your turn runs longer than ~10s after the script returns, the prompt could be typed before the wipe completes. Stop promptly after running.

ARGUMENTS: Resume M3c (signed self-update) — LAST M3 task. Read M3c-PLAN.md + ADR-0004 §A/§D, expand the C0–C4 stub into a full JIT task layer, then start C0: brain-only zero-interruption update over the existing M3b broker/brain handoff path (REQ-UPD-3, prove E2E). Activate REQ-UPD-* per task; REQ-UPD-1 stays [] until M4. M3b is shipped + CI-green (commit 2573101). Discipline: tag evidence same-commit, traceable-reqs check + clippy -D warnings + CI matrix green before done; push only when user asks.


/clear


Note: the script types a fixed re-seed (`proceed with next steps`) — my resume detail lives in the commune (now refreshing Psyche) + `M3c-PLAN.md` + ROADMAP, which the clean session reads via SessionStart reorientation. Arming.


PowerShell

Arm self-clear: queue /clear + detached waker


SELF-CLEAR armed: id=doyle winpid=29328 hwnd=133338 — /clear queued; self-note typed in ~10s


Armed (id=doyle, winpid=29328). `/clear` queued, waker types re-seed in ~10s. Ending turn now so the wipe fires.

M3b shipped + CI-green; commune refreshed Psyche with M3c resume pointer. See you next session.