`spt ready <id> --once` exits **rc=0** with `READY` on stderr, empty stdout → my runner's `is_actionable` is false → should `continue` and loop forever. Yet isolated runner exited 0 (loop broke = `spt ready` returned non-zero somewhere). Contradiction. Reproduce the loop: call `spt ready --once` repeatedly, watch for a call that flips non-zero.


Bash

PID=sptc-psy-loop-test
export SPT_AGENT_ID="$PID" OWL_SESSION_ID="$PID"
for n in 1 2 3 4 5; do
  out=$(timeout 8 spt ready "$PID" --once 2>/tmp/err.$n)
  rc=$?
  echo "call $n: rc=$rc  stdout=[$out]  stderr=[$(cat /tmp/err.$n | tr '\n' '|')]"
done
spt endpoint stop "$PID" 2>/dev/null; spt endpoint shutdown "$PID" 2>/dev/null

Call spt ready --once repeatedly, find the failing iteration


call 1: rc=0  stdout=[]  stderr=[READY:sptc-psy-loop-test|]
call 2: rc=0  stdout=[]  stderr=[READY:sptc-psy-loop-test|]
call 3: rc=0  stdout=[]  stderr=[READY:sptc-psy-loop-test|]
call 4: rc=0  stdout=[]  stderr=[READY:sptc-psy-loop-test|]
call 5: rc=0  stdout=[]  stderr=[READY:sptc-psy-loop-test|]


Correction: that "exit 0" was `head`'s exit code, not the runner's (pipe bug). Inconclusive. Two real signals emerging though: `spt ready --once` returns **immediately** on empty drain (rc=0, empty stdout) — it does **not block** for a pulse as the runner's design assumed → my loop would **busy-spin hot**, not block. Redo the isolated runner test capturing its TRUE exit, no pipe.


cd "C:\Users\decid\Documents\projects\spt-claude-code"
# shim a fast no-op claude so seed costs nothing — name claude.exe won't work; use a .cmd on PATH
SHIM=$(mktemp -d)
cat > "$SHIM/claude.cmd" <<'EOF'
@echo off
echo [shim] %*
exit /b 0
EOF
PSY=~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe
PID=sptc-psy-iso2
export PATH="$SHIM:$PATH"
export SPT_AGENT_ID="$PID" OWL_SESSION_ID="$PID"
LOG=$(mktemp)
timeout 12 "$PSY" --id "$PID" --session-id sptc-psy-iso2-sess --prompt "test psyche" > "$LOG" 2>&1
rc=$?
echo "=== runner true exit rc=$rc (124=stayed resident full 12s; 0=loop broke) ==="
echo "=== runner output (first/last) ==="; head -6 "$LOG"; echo "..."; tail -3 "$LOG"
echo "=== output line count (busy-loop would spam) ==="; wc -l < "$LOG"
rm -rf "$SHIM" "$LOG"
spt endpoint stop "$PID" 2>/dev/null; spt endpoint shutdown "$PID" 2>/dev/null

Isolated runner true exit + busy-loop check


=== runner true exit rc=124 (124=stayed resident full 12s; 0=loop broke) ===
=== runner output (first/last) ===
...
=== output line count (busy-loop would spam) ===
0


Decisive: rc=124 — MY runner **stays resident** the full 12s when launched manually against a non-daemon perch. So the adapter runner is healthy in isolation. The death only happens in the daemon-spawned context. Prime suspect: the daemon child env lacks the identity vars (`OWL_SESSION_ID`/`SPT_AGENT_ID`) my runner's `spt ready <id> --once` relies on → auth/bind fails → non-zero → runner exits as "perch gone." Test `spt ready` with identity env stripped.


PID=sptc-psy-noenv
echo "=== spt ready <id> --once with NO identity env (simulates daemon child) ==="
env -u OWL_SESSION_ID -u SPT_AGENT_ID -u SPTC_CI_ID timeout 8 spt ready "$PID" --once
rc=$?
echo "=== rc=$rc (non-zero => runner would treat as perch-gone and EXIT) ==="
spt endpoint stop "$PID" 2>/dev/null; spt endpoint shutdown "$PID" 2>/dev/null

Test spt ready without identity env


=== spt ready <id> --once with NO identity env (simulates daemon child) ===
READY:sptc-psy-noenv
=== rc=0 (non-zero => runner would treat as perch-gone and EXIT) ===


Re-run psyche proof on v0.8.1 + read psyche-host line

SPTC_ACCEPTANCE=1 sh ci/psyche/live-relay-int.sh on 0.8.1 → psyche leg flips skip→assert. Check spt endpoint list/whoami for psyche-host: line (FAILED reason or clean).

REQ-INSTALL-11 proof: drop F-006 PATH interim

Remove ~/AppData/Local/spt-core/bin/claude-spt-{psyche,digest}.exe → confirm psyche still spawns from install-dir. Drop interim PATH-copy step from setup.md step 4 + plugin SKILL.md step 3.

Per-OS re-release (windows + linux .spt)

Pack adapter-windows-x86_64.spt + adapter-linux-x86_64.spt (zigbuild cross), attach BOTH to new GitHub release with updated manifest ([update] block + dropped interim).

Live gh_release auto-update test

With new release, spt adapter update pulls it. Re-acquire new [update]-bearing manifest first.

cplugs republish v0.1.2

AFTER per-OS release. Ships os-detecting setup floor + ccs (#7) + subnet (#3/#4) + dropped interim.


rc=0 without identity env too — `spt ready` derives perch from the positional id. Env-collision hypothesis dead. Need direct evidence of the psyche process lifecycle in the daemon context. Bringup, then sample for `claude-spt-psyche.exe` every 2s from spawn — see exactly how long it lives.


cd "C:\Users\decid\Documents\projects\spt-claude-code"
A=claude-spt:live; MAN="$PWD/adapter/claude-spt.toml"
ID=sptc-diag-life; SID=sptc-diag-life-sess
export SPTC_CI_ID="$ID" SPT_AGENT_ID="$ID" OWL_SESSION_ID="$ID"
ANCHOR=$(ps -p $$ 2>/dev/null | awk 'NR==2{print $4}'); case "$ANCHOR" in ''|*[!0-9]*) ANCHOR=$$;; esac
LF=$(mktemp)
spt api --adapter "$A" --manifest "$MAN" seed --pid "$ANCHOR" --session-id "$SID" >/dev/null 2>&1
timeout 70 spt api --adapter "$A" --manifest "$MAN" listen --parent-pid "$ANCHOR" "$ID" > "$LF" 2>&1 &
i=0; while [ "$i" -lt 25 ]; do grep -q "READY:$ID" "$LF" 2>/dev/null && break; sleep 1; i=$((i+1)); done
echo "READY at t=0. Sampling psyche + claude procs every 2s:"
for t in 0 2 4 6 8 10 12 14 16 18 20 24 28; do
  psy=$(tasklist 2>/dev/null | grep -ci "claude-spt-psyche")
  cla=$(tasklist 2>/dev/null | grep -ci "^claude.exe\|node.exe")
  nested=$([ -f ~/AppData/Local/spt-core/owlery/$ID/nested/$ID-psyche/info.json ] && echo yes || echo no)
  echo "t=${t}s  psyche_procs=$psy  nested_infojson=$nested"
  sleep 2
done
echo "=== final nested info.json ==="; cat ~/AppData/Local/spt-core/owlery/$ID/nested/$ID-psyche/info.json 2>&1
echo ""; echo "=== parent psyche_host_error? ==="; cat ~/AppData/Local/spt-core/owlery/$ID/info.json 2>&1 | grep -o "psyche_host_error[^}]*}" || echo "  (no psyche_host_error field)"

Sample psyche process lifecycle every 2s


Command running in background with ID: bjma7t3dz. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\bjma7t3dz.output. You will be notified when it completes. To check interim output, use Read on that file path.


Sampling running (~60s). Wait for the lifecycle trace — it's decisive evidence of whether the psyche exits immediately, runs-seed-then-exits, or stays-then-dies.


1	READY at t=0. Sampling psyche + claude procs every 2s:
2	t=0s  psyche_procs=0  nested_infojson=yes
3	t=2s  psyche_procs=0  nested_infojson=yes
4

READY at t=0. Sampling psyche + claude procs every 2s:
t=0s  psyche_procs=0  nested_infojson=yes
t=2s  psyche_procs=0  nested_infojson=yes


t=0 and t=2: `psyche_procs=0` but `nested_infojson=yes` — daemon writes the nested info.json (online + pid) but **no actual process exists even at t=0**. Either it dies in <2s or the daemon records online without a live process. Wait for full trace.


1	READY at t=0. Sampling psyche + claude procs every 2s:
2	t=0s  psyche_procs=0  nested_infojson=yes
3	t=2s  psyche_procs=0  nested_infojson=yes
4	t=4s  psyche_procs=0  nested_infojson=yes
5	t=6s  psyche_procs=0  nested_infojson=yes
6	t=8s  psyche_procs=0  nested_infojson=yes
7

READY at t=0. Sampling psyche + claude procs every 2s:
t=0s  psyche_procs=0  nested_infojson=yes
t=2s  psyche_procs=0  nested_infojson=yes
t=4s  psyche_procs=0  nested_infojson=yes
t=6s  psyche_procs=0  nested_infojson=yes
t=8s  psyche_procs=0  nested_infojson=yes


<task-notification>
<task-id>bjma7t3dz</task-id>
<tool-use-id>toolu_01PJk2ZaEVRxSERzFUtiqbwV</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\bjma7t3dz.output</output-file>
<status>completed</status>
<summary>Background command "Sample psyche process lifecycle every 2s" completed (exit code 0)</summary>
</task-notification>


Consistent `psyche_procs=0` from t=0. Daemon writes nested info.json (online + pid) but the spawned `claude-spt-psyche` exits in <2s — while my **manual** run stayed resident 12s. The difference is the daemon-spawned context. Strong new hypothesis: the at-logon **daemon's PATH** lacks the dir holding `spt.exe`/`claude`, so the runner's `Command::new("spt")` hits `Err(_) => return ExitCode::SUCCESS` and exits instantly. Check where spt/claude live vs a logon PATH.


echo "=== spt location ==="; command -v spt; echo "=== claude location ==="; command -v claude
echo "=== current PATH dirs holding them ==="; command -v spt | xargs dirname; command -v claude 2>/dev/null | xargs dirname 2>/dev/null
echo "=== is spt dir on MACHINE/USER PATH (persistent, daemon would inherit)? ==="
powershell.exe -NoProfile -Command "[Environment]::GetEnvironmentVariable('PATH','User')" 2>/dev/null | tr ';' '\n' | grep -iE "spt|claude|npm|local/bin"
echo "--- machine ---"
powershell.exe -NoProfile -Command "[Environment]::GetEnvironmentVariable('PATH','Machine')" 2>/dev/null | tr ';' '\n' | grep -iE "spt|claude"

Find spt/claude locations vs persistent PATH


=== spt location ===
/c/Users/decid/AppData/Local/spt-core/bin/spt
=== claude location ===
/c/Users/decid/.local/bin/claude
=== current PATH dirs holding them ===
/c/Users/decid/AppData/Local/spt-core/bin
/c/Users/decid/.local/bin
=== is spt dir on MACHINE/USER PATH (persistent, daemon would inherit)? ===
C:\Users\decid\AppData\Local\spt-core\bin
--- machine ---


Prompt:
<task-notification>
<task-id>bjma7t3dz</task-id>
<tool-use-id>toolu_01PJk2ZaEVRxSERzFUtiqbwV</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\bjma7t3dz.output</output-file>
<status>completed</status>
<summary>Background command "Sample psyche process lifecycle every 2s" completed (exit code 0)</summary>
</task-notification>


1	READY at t=0. Sampling psyche + claude procs every 2s:
2	t=0s  psyche_procs=0  nested_infojson=yes
3	t=2s  psyche_procs=0  nested_infojson=yes
4	t=4s  psyche_procs=0  nested_infojson=yes
5	t=6s  psyche_procs=0  nested_infojson=yes
6	t=8s  psyche_procs=0  nested_infojson=yes
7	t=10s  psyche_procs=0  nested_infojson=yes
8	t=12s  psyche_procs=0  nested_infojson=yes
9	t=14s  psyche_procs=0  nested_infojson=yes
10	t=16s  psyche_procs=0  nested_infojson=yes
11	t=18s  psyche_procs=0  nested_infojson=yes
12	t=20s  psyche_procs=0  nested_infojson=yes
13	t=24s  psyche_procs=0  nested_infojson=yes
14	t=28s  psyche_procs=0  nested_infojson=yes
15	=== final nested info.json ===
16	{"id":"sptc-diag-life-psyche","started":"epoch-ms:1781652254542","pid":232712,"session_id":"sptc-diag-life-sess","state":"psyche","status":"online"}
17	=== parent psyche_host_error? ===
18	  (no psyche_host_error field)
19

READY at t=0. Sampling psyche + claude procs every 2s:
t=0s  psyche_procs=0  nested_infojson=yes
t=2s  psyche_procs=0  nested_infojson=yes
t=4s  psyche_procs=0  nested_infojson=yes
t=6s  psyche_procs=0  nested_infojson=yes
t=8s  psyche_procs=0  nested_infojson=yes
t=10s  psyche_procs=0  nested_infojson=yes
t=12s  psyche_procs=0  nested_infojson=yes
t=14s  psyche_procs=0  nested_infojson=yes
t=16s  psyche_procs=0  nested_infojson=yes
t=18s  psyche_procs=0  nested_infojson=yes
t=20s  psyche_procs=0  nested_infojson=yes
t=24s  psyche_procs=0  nested_infojson=yes
t=28s  psyche_procs=0  nested_infojson=yes
=== final nested info.json ===
{"id":"sptc-diag-life-psyche","started":"epoch-ms:1781652254542","pid":232712,"session_id":"sptc-diag-life-sess","state":"psyche","status":"online"}
=== parent psyche_host_error? ===
  (no psyche_host_error field)


Decisive: across 28s, `psyche_procs=0` at every 2s sample (including t=0), yet nested info.json says `online` with pid 232712 and **no `psyche_host_error`**. So the daemon records the psyche hosted but no `claude-spt-psyche` process ever lives. Need to know: does the runner exit instantly under the *daemon's exact spawn conditions* (cwd=nested psyche dir, `claude` off-PATH, stdio detached)? Replicate precisely + capture stderr.


# Is the recorded pid even real?
echo "=== does recorded pid 232712 exist? ==="; tasklist //FI "PID eq 232712" 2>&1 | grep -iE "232712|no tasks"
PSY=~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe
NESTED=~/AppData/Local/spt-core/owlery/sptc-diag-life/nested/sptc-diag-life-psyche
mkdir -p "$NESTED" 2>/dev/null
# Replicate daemon env: spt-core/bin ON path, .local/bin (claude) OFF path. cwd=nested dir.
DAEMON_PATH="/c/Users/decid/AppData/Local/spt-core/bin:/c/Windows/System32:/c/Windows"
LOG=$(mktemp)
echo "=== run runner under daemon-like conditions (cwd=nested, claude OFF path, 12s) ==="
( cd "$NESTED" && env -i PATH="$DAEMON_PATH" SystemRoot=/c/Windows USERPROFILE="$HOME" \
    timeout 12 "$PSY" --id sptc-diag-life-psyche --session-id sptc-diag-life-sess --prompt "you are a psyche" ) > "$LOG" 2>&1
rc=$?
echo "=== rc=$rc (124=stayed resident; 0/other=exited early) ==="
echo "=== captured output ==="; cat "$LOG"
rm -f "$LOG"

Replicate daemon spawn conditions, capture runner behavior


=== does recorded pid 232712 exist? ===
INFO: No tasks are running which match the specified criteria.
=== run runner under daemon-like conditions (cwd=nested, claude OFF path, 12s) ===
=== rc=1 (124=stayed resident; 0/other=exited early) ===
=== captured output ===
ERROR: Invalid syntax. Default option is not allowed more than '1' time(s).
Type "TIMEOUT /?" for usage.


Live gh_release auto-update test

cplugs republish v0.1.2


pid 232712 doesn't exist — daemon recorded a phantom pid. My replication broke: `env -i` wiped PATH so `timeout` resolved to Windows `TIMEOUT.EXE`. Redo with gnu timeout by abspath, child PATH = daemon-like.


TO=$(command -v timeout)  # gnu timeout abspath, immune to child PATH
echo "gnu timeout: $TO"
PSY=~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe
NESTED=~/AppData/Local/spt-core/owlery/sptc-diag-life/nested/sptc-diag-life-psyche
mkdir -p "$NESTED" 2>/dev/null
DAEMON_PATH="/c/Users/decid/AppData/Local/spt-core/bin:/c/Windows/System32:/c/Windows"
LOG=$(mktemp)
echo "=== runner: cwd=nested, child PATH=daemon-like (spt yes, claude no), 12s ==="
( cd "$NESTED" && PATH="$DAEMON_PATH" "$TO" 12 "$PSY" --id sptc-diag-life-psyche --session-id sptc-diag-life-sess --prompt "you are a psyche" ) > "$LOG" 2>&1
rc=$?
echo "=== rc=$rc (124=resident OK; else exited early) ==="
echo "=== output ==="; cat "$LOG" | head -20
rm -f "$LOG"

Replicate daemon spawn, gnu timeout by abspath


gnu timeout: /usr/bin/timeout
=== runner: cwd=nested, child PATH=daemon-like (spt yes, claude no), 12s ===
=== rc=124 (124=resident OK; else exited early) ===
=== output ===


rc=124 — runner stays **resident** even under daemon-like conditions (nested cwd, `claude` off-PATH, only `spt` on-PATH). So the adapter runner is correct. The daemon writes the psyche `online` with a **phantom pid** and no process ever runs, no `psyche_host_error`.

New leading hypothesis: the at-logon **daemon is long-running with a stale PATH** captured at an earlier logon — before `spt-core/bin` was on PATH. The runner it spawns inherits that stale PATH → `Command::new("spt")` fails → runner hits `Err(_) => ExitCode::SUCCESS` and exits instantly. Decisive test: restart the daemon (refresh its env), re-run the lifecycle sample.


Daemon subcommands


The per-machine daemon: run, stop, or read node status.

Bare `spt daemon` renders the node status view — daemon state, member subnets, local endpoints (M8
decision 25).

Usage: spt.exe daemon [COMMAND]

Commands:
  run     Run the per-machine daemon in the FOREGROUND — this process IS the daemon, blocking until
          signalled (the service unit's ExecStart, or manual debugging). Never detaches; for a
          background daemon use `start`
  start   Ensure the daemon is up in the background (idempotent, service-aware): a registered OS
          service is driven via its manager, else a detached daemon is spawned. Non-blocking
  stop    Stop the daemon (service-aware: a managed service is stopped via its manager so it does
          not auto-restart-fight; else a graceful IPC stop)
  status  Node status: daemon state, member subnets, local endpoints (the bare `spt daemon` view)
  help    Print this message or the help of the given subcommand(s)

Options:
  -h, --help


Before restarting — important: `spt update apply` replaced the binary, but the **running daemon (pid 145328) may still be old-code with stale env**. If so, my "v0.8.1 reconcile" observations were against a stale daemon. Restart guarantees v0.8.1 daemon code + fresh PATH. Do it, then retest the lifecycle.


echo "=== daemon before ==="; spt daemon status 2>&1 | head -3
spt daemon stop 2>&1 | tail -3
sleep 2
spt daemon start 2>&1 | tail -3
sleep 3
echo "=== daemon after ==="; spt daemon status 2>&1 | head -4

Restart daemon for fresh env + v0.8.1 code


=== daemon before ===
daemon: running (pid 145328)
peer pump: STALLED (last tick 520s ago — advertising/connecting is not running; restart the daemon)
managed-by: manual — at-logon task 'spt-core daemon' registered (auto-starts at logon)
DAEMON_STOPPED
DAEMON_STARTED
=== daemon after ===
daemon: running (pid 91368)
peer pump: live (last tick 2s ago)
managed-by: manual — at-logon task 'spt-core daemon' registered (auto-starts at logon)
subnets:


There it is — the old daemon (145328) showed **`peer pump: STALLED (520s)`** — doyle's exact correlate. All my prior runs hit a stalled, possibly-old-code daemon. Fresh daemon (91368): **`peer pump: live`**. NOW the real v0.8.1 test. Re-run the lifecycle sample against the fresh daemon.


cd "C:\Users\decid\Documents\projects\spt-claude-code"
A=claude-spt:live; MAN="$PWD/adapter/claude-spt.toml"
ID=sptc-fresh-life; SID=sptc-fresh-life-sess
export SPTC_CI_ID="$ID" SPT_AGENT_ID="$ID" OWL_SESSION_ID="$ID"
ANCHOR=$(ps -p $$ 2>/dev/null | awk 'NR==2{print $4}'); case "$ANCHOR" in ''|*[!0-9]*) ANCHOR=$$;; esac
LF=$(mktemp)
spt api --adapter "$A" --manifest "$MAN" seed --pid "$ANCHOR" --session-id "$SID" >/dev/null 2>&1
timeout 70 spt api --adapter "$A" --manifest "$MAN" listen --parent-pid "$ANCHOR" "$ID" > "$LF" 2>&1 &
i=0; while [ "$i" -lt 25 ]; do grep -q "READY:$ID" "$LF" 2>/dev/null && break; sleep 1; i=$((i+1)); done
echo "READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:"
for t in 0 3 6 9 12 15 18 21 24 27 30; do
  psy=$(tasklist 2>/dev/null | grep -ci "claude-spt-psyche")
  echo "t=${t}s  claude-spt-psyche_procs=$psy"
  sleep 3
done
echo "=== nested psyche info.json ==="; cat ~/AppData/Local/spt-core/owlery/$ID/nested/$ID-psyche/info.json 2>&1
echo ""; echo "=== parent psyche_host_error? ==="; grep -o "psyche_host_error[^}]*}" ~/AppData/Local/spt-core/owlery/$ID/info.json 2>&1 || echo "  (none)"
echo "=== endpoint list (psyche perch visible?) ==="; spt endpoint list 2>&1 | grep -i psyche || echo "  no psyche in endpoint list"

Re-run psyche lifecycle against fresh daemon


Command running in background with ID: buwrsh9x5. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\buwrsh9x5.output. You will be notified when it completes. To check interim output, use Read on that file path.


Decisive test running against fresh v0.8.1 daemon (~40s). Waiting.


1	READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
2	t=0s  claude-spt-psyche_procs=0
3

READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0


1	READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
2	t=0s  claude-spt-psyche_procs=0
3	t=3s  claude-spt-psyche_procs=0
4

READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0
t=3s  claude-spt-psyche_procs=0


Let me wait for completion rather than poll.


1	READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
2	t=0s  claude-spt-psyche_procs=0
3	t=3s  claude-spt-psyche_procs=0
4	t=6s  claude-spt-psyche_procs=0
5

READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0
t=3s  claude-spt-psyche_procs=0
t=6s  claude-spt-psyche_procs=0


I'll wait for the task-completion notification rather than poll.


ToolSearch

Question: select:Monitor


Question: select:Monitor


1	READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
2	t=0s  claude-spt-psyche_procs=0
3	t=3s  claude-spt-psyche_procs=0
4	t=6s  claude-spt-psyche_procs=0
5	t=9s  claude-spt-psyche_procs=0
6	t=12s  claude-spt-psyche_procs=0
7	t=15s  claude-spt-psyche_procs=0
8	t=18s  claude-spt-psyche_procs=0
9

READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0
t=3s  claude-spt-psyche_procs=0
t=6s  claude-spt-psyche_procs=0
t=9s  claude-spt-psyche_procs=0
t=12s  claude-spt-psyche_procs=0
t=15s  claude-spt-psyche_procs=0
t=18s  claude-spt-psyche_procs=0


Still 0 with fresh daemon. Restart didn't fix it. Waiting for the final info.json + error fields.


1	READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
2	t=0s  claude-spt-psyche_procs=0
3	t=3s  claude-spt-psyche_procs=0
4	t=6s  claude-spt-psyche_procs=0
5	t=9s  claude-spt-psyche_procs=0
6	t=12s  claude-spt-psyche_procs=0
7	t=15s  claude-spt-psyche_procs=0
8	t=18s  claude-spt-psyche_procs=0
9	t=21s  claude-spt-psyche_procs=0
10

READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0
t=3s  claude-spt-psyche_procs=0
t=6s  claude-spt-psyche_procs=0
t=9s  claude-spt-psyche_procs=0
t=12s  claude-spt-psyche_procs=0
t=15s  claude-spt-psyche_procs=0
t=18s  claude-spt-psyche_procs=0
t=21s  claude-spt-psyche_procs=0


Bash

F="C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\buwrsh9x5.output"
i=0; while [ $i -lt 30 ]; do grep -q "endpoint list" "$F" 2>/dev/null && break; sleep 2; i=$((i+1)); done
cat "$F"

Wait for fresh-daemon run to finish, dump result


<task-notification>
<task-id>buwrsh9x5</task-id>
<tool-use-id>toolu_011WXdESPMcUUTVjjmhMd3h7</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\buwrsh9x5.output</output-file>
<status>completed</status>
<summary>Background command "Re-run psyche lifecycle against fresh daemon" completed (exit code 0)</summary>
</task-notification>


READY at t=0 (FRESH daemon). Sampling psyche procs every 3s:
t=0s  claude-spt-psyche_procs=0
t=3s  claude-spt-psyche_procs=0
t=6s  claude-spt-psyche_procs=0
t=9s  claude-spt-psyche_procs=0
t=12s  claude-spt-psyche_procs=0
t=15s  claude-spt-psyche_procs=0
t=18s  claude-spt-psyche_procs=0
t=21s  claude-spt-psyche_procs=0
t=24s  claude-spt-psyche_procs=0
t=27s  claude-spt-psyche_procs=0
t=30s  claude-spt-psyche_procs=0
=== nested psyche info.json ===
{"id":"sptc-fresh-life-psyche","started":"epoch-ms:1781652436299","pid":14584,"session_id":"sptc-fresh-life-sess","state":"psyche","status":"online"}
=== parent psyche_host_error? ===
  (none)
=== endpoint list (psyche perch visible?) ===
  no psyche in endpoint list


Prompt:
<task-notification>
<task-id>buwrsh9x5</task-id>
<tool-use-id>toolu_011WXdESPMcUUTVjjmhMd3h7</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-claude-code\3fdfaa52-7de3-4b1b-b4b5-594320901cba\tasks\buwrsh9x5.output</output-file>
<status>completed</status>
<summary>Background command "Re-run psyche lifecycle against fresh daemon" completed (exit code 0)</summary>
</task-notification>


Same with fresh v0.8.1 daemon (pid 91368, peer pump live): psyche `online` + phantom pid 14584, no `psyche_host_error`, **zero processes** across 30s. Daemon restart did NOT fix it. Since my runner provably stays resident under replicated spawn conditions, the gap is in how the daemon launches it. One untested angle: the daemon's `psyche_init` command **templating** — if an arg is malformed/empty, the runner exits 2 instantly on arg-parse. Check the manifest's `[session.psyche_init]` block.


175	window_turns = 20
176
177	[profiles.deep.strings]
178	adapter_label = "Claude Code (spt, deep)"
179
180	# `live` — the LiveAgent overlay. Selected as `claude-spt:live`; /sptc:live activates this profile so
181	# the daemon resolves the MERGED manifest, sees [session.psyche_init], and spawns the endpoint's
182	# Psyche companion (base claude-spt declares no psyche_init = ReadyAgent — doyle 2026-06-15, traced:
183	# api seed stores the composite → info.json adapter → daemon resolve_option_in applies the overlay →
184	# BrainLifecycle checks psyche_init on the merged view). Psyche keys the daemon fills are EXACTLY
185	# {id, session_id, psyche_dir, psyche_prompt} — NOT {session_name} (a [session.self] fill; the
186	# published example's use of it is a bug doyle fixed) and NOT {psyche_context} (the resume-preload
187	# seam, not first spawn). `detach=true` + cwd={psyche_dir}: the Psyche is fire-and-forget detached
188	# (Stdio::null, unsupervised), owns its `<parent>-psyche` perch, authors commune-drops only, exits at
189	# session end. The runner (claude-spt-psyche, tools/) is BUILT: a resident wrapper — `claude -p` is
190	# one-shot, so it seeds the Psyche `claude` session once from {psyche_prompt} then drives one
191	# `claude --continue` turn per daemon pulse (NOT a bare `claude -p`), the claude authoring commune
192	# drops. Activation: /sptc:live brings the perch up under the `claude-spt:live` composite (the overlay
193	# the daemon resolves to see psyche_init) + runs the delivery relay via Monitor (skills/live.md).
194	# [impl->REQ-SKILL-LIVE]
195	[profiles.live.session.psyche_init]
196	command = "claude-spt-psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
197	cwd = "{psyche_dir}"
198	detach = true
199	keys = ["id", "session_id", "psyche_dir", "psyche_prompt"]
200
201	# `ccs` — the LOCKED-ADD ccs profile template. Selected as `claude-spt:ccs`. ccs is the model/billing
202	# multiplexer (kaitranntt/ccs): structurally Claude Code, only the launch binary + backend differ —
203	# exactly the profile seam (SCOPE §ccs, LOCKED). This overlay leaf-replaces ONE leaf: the spawn
204	# command. `ccs` (bare) is a DROP-IN for `claude` and launches the user's ccs *default* account
205	# (set via `ccs auth default <name>`); SPT_ENDPOINT_ID rides the inherited env through the wrapper
206	# exactly as for bare `claude`. Validated against the known-good sister project claude_skill_owl,
207	# which latches `cli_binary = "ccs"` as a same-argv `claude` substitute (live/wrapper/claude.rs).
208	# This profile IS the worked example of adapter-profile authoring + the operator's own SPT-ecosystem
209	# hook; a user wanting a *specific* ccs profile clones it with `command = "ccs glm"` etc.
210	#
211	# NO `[profiles.ccs.digest]` leaf by design: ccs relocates Claude Code's transcript tree via the
212	# CLAUDE_CONFIG_DIR env var (e.g. ~/.ccs/instances/<account>/.claude), whose value is set by ccs at
213	# runtime per-account and is NOT expressible as a static catalog path. The "per-profile ~/.ccs log
214	# dir" SCOPE requirement is therefore honored IN THE EXTRACTOR (claude-spt-digest prefers
215	# $CLAUDE_CONFIG_DIR/projects over the base ~/.claude/projects on its dir-locate branch — the
216	# owl-validated owlery::claude_projects_root pattern), so the base [digest] config serves both base
217	# and ccs sessions transparently. That env-aware resolver is REQ-CCS-PROFILES's impl evidence.
218	# (REQ-CCS-PROFILES doc evidence lives in docs/PARITY.md; int = registration-int :ccs assertions.)
219	[profiles.ccs.session.self]
220	command = "ccs"
221
222	# Observability leaf (mirrors :deep): makes the overlay visible via `spt adapter get-string
223	# claude-spt:ccs adapter_label` (differs from base) — the registration-int resolve proof.
224	[profiles.ccs.strings]
225	adapter_label = "Claude Code (spt, ccs)"
226
227	# ── [strings] ──────────────────────────────────────────────────────────────────────────────
228	# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.
229	# Seed only this slice — the file-backed skill-instruction BODIES (UPS-injection source) land in
230	# the M12 file-backed-strings parity slice. A profile may leaf-replace any key here.
231	[strings]
232	adapter_label = "Claude Code (spt)"
233
234	[strings.notice]
235	not_installed = "spt-core not found on PATH — run /sptc:setup to install it."
236
237	[strings.skills]
238	# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
239	# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
240	# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
241	# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
242	# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
243	# whoami SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
244	# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
245	# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
246	setup = { file = "skills/setup.md" }
247	ready = { file = "skills/ready.md" }
248	send = { file = "skills/send.md" }
249	version = { file = "skills/version.md" }
250	signoff = { file = "skills/signoff.md" }       # -> spt endpoint shutdown (graceful + final save)
251	list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
252	commune = { file = "skills/commune.md" }       # -> file-drop <id>-commune.md into [session].commune_dir
253	force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
254	subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
255	# `live` -> upgrade THIS session to a LiveAgent via the `claude-spt:live` profile (overlays
256	# [session.psyche_init] -> daemon spawns the Psyche) + a resident Monitor relay as the delivery pipe.
257	# Profile propagation CONFIRMED in spt-core code (doyle 2026-06-15: api seed stores the composite ->
258	# info.json adapter -> daemon resolve_option_in applies the overlay -> BrainLifecycle checks
259	# psyche_init on the merged view). Body authored; runner (claude-spt-psyche) built (tools/).
260	live = { file = "skills/live.md" }             # -> activate claude-spt:live + Monitor relay (LiveAgent)
261	# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
262	# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
263	# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
264	# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).

window_turns = 20

[profiles.deep.strings]
adapter_label = "Claude Code (spt, deep)"

# `live` — the LiveAgent overlay. Selected as `claude-spt:live`; /sptc:live activates this profile so
# the daemon resolves the MERGED manifest, sees [session.psyche_init], and spawns the endpoint's
# Psyche companion (base claude-spt declares no psyche_init = ReadyAgent — doyle 2026-06-15, traced:
# api seed stores the composite → info.json adapter → daemon resolve_option_in applies the overlay →
# BrainLifecycle checks psyche_init on the merged view). Psyche keys the daemon fills are EXACTLY
# {id, session_id, psyche_dir, psyche_prompt} — NOT {session_name} (a [session.self] fill; the
# published example's use of it is a bug doyle fixed) and NOT {psyche_context} (the resume-preload
# seam, not first spawn). `detach=true` + cwd={psyche_dir}: the Psyche is fire-and-forget detached
# (Stdio::null, unsupervised), owns its `<parent>-psyche` perch, authors commune-drops only, exits at
# session end. The runner (claude-spt-psyche, tools/) is BUILT: a resident wrapper — `claude -p` is
# one-shot, so it seeds the Psyche `claude` session once from {psyche_prompt} then drives one
# `claude --continue` turn per daemon pulse (NOT a bare `claude -p`), the claude authoring commune
# drops. Activation: /sptc:live brings the perch up under the `claude-spt:live` composite (the overlay
# the daemon resolves to see psyche_init) + runs the delivery relay via Monitor (skills/live.md).
# [impl->REQ-SKILL-LIVE]
[profiles.live.session.psyche_init]
command = "claude-spt-psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
cwd = "{psyche_dir}"
detach = true
keys = ["id", "session_id", "psyche_dir", "psyche_prompt"]

# `ccs` — the LOCKED-ADD ccs profile template. Selected as `claude-spt:ccs`. ccs is the model/billing
# multiplexer (kaitranntt/ccs): structurally Claude Code, only the launch binary + backend differ —
# exactly the profile seam (SCOPE §ccs, LOCKED). This overlay leaf-replaces ONE leaf: the spawn
# command. `ccs` (bare) is a DROP-IN for `claude` and launches the user's ccs *default* account
# (set via `ccs auth default <name>`); SPT_ENDPOINT_ID rides the inherited env through the wrapper
# exactly as for bare `claude`. Validated against the known-good sister project claude_skill_owl,
# which latches `cli_binary = "ccs"` as a same-argv `claude` substitute (live/wrapper/claude.rs).
# This profile IS the worked example of adapter-profile authoring + the operator's own SPT-ecosystem
# hook; a user wanting a *specific* ccs profile clones it with `command = "ccs glm"` etc.
#
# NO `[profiles.ccs.digest]` leaf by design: ccs relocates Claude Code's transcript tree via the
# CLAUDE_CONFIG_DIR env var (e.g. ~/.ccs/instances/<account>/.claude), whose value is set by ccs at
# runtime per-account and is NOT expressible as a static catalog path. The "per-profile ~/.ccs log
# dir" SCOPE requirement is therefore honored IN THE EXTRACTOR (claude-spt-digest prefers
# $CLAUDE_CONFIG_DIR/projects over the base ~/.claude/projects on its dir-locate branch — the
# owl-validated owlery::claude_projects_root pattern), so the base [digest] config serves both base
# and ccs sessions transparently. That env-aware resolver is REQ-CCS-PROFILES's impl evidence.
# (REQ-CCS-PROFILES doc evidence lives in docs/PARITY.md; int = registration-int :ccs assertions.)
[profiles.ccs.session.self]
command = "ccs"

# Observability leaf (mirrors :deep): makes the overlay visible via `spt adapter get-string
# claude-spt:ccs adapter_label` (differs from base) — the registration-int resolve proof.
[profiles.ccs.strings]
adapter_label = "Claude Code (spt, ccs)"

# ── [strings] ──────────────────────────────────────────────────────────────────────────────
# Opaque adapter data (spt-core never executes it), dot-path-readable via `spt adapter get-string`.
# Seed only this slice — the file-backed skill-instruction BODIES (UPS-injection source) land in
# the M12 file-backed-strings parity slice. A profile may leaf-replace any key here.
[strings]
adapter_label = "Claude Code (spt)"

[strings.notice]
not_installed = "spt-core not found on PATH — run /sptc:setup to install it."

[strings.skills]
# Operative UPS-injection skill bodies. File-backed values (`{ file = "skills/<x>.md" }`) live in the
# adapter aux dir `adapter/strings/skills/` (copied to `adapters/<adapter>/strings/` on `adapter add`,
# resolved lazily at `spt adapter get-string` — proven vs live v0.7.0, F-003). File-backed so the
# manifest does not bloat (ADR-0001). Skills not yet authored keep a one-line inline summary until
# their body lands; converting an inline summary to a `{ file }` pointer is the per-skill increment.
# whoami SKILL dropped (SCOPE LOCKED parity-trim; operator-confirmed 2026-06-15): core `spt whoami`
# stays in the hot path (-> M12 `endpoint list` alias), so the adapter ships no whoami skill. The
# "who am i" hint below points at the core CLI. (docs/PARITY.md.)
setup = { file = "skills/setup.md" }
ready = { file = "skills/ready.md" }
send = { file = "skills/send.md" }
version = { file = "skills/version.md" }
signoff = { file = "skills/signoff.md" }       # -> spt endpoint shutdown (graceful + final save)
list-agents = { file = "skills/list-agents.md" } # -> spt endpoint list (the roster)
commune = { file = "skills/commune.md" }       # -> file-drop <id>-commune.md into [session].commune_dir
force-stop = { file = "skills/force-stop.md" } # -> spt endpoint shutdown (graceful, tears down Psyche)
subnet = { file = "skills/subnet.md" }         # -> spt subnet {status,create,show-code,join} (cross-machine); REQ-SKILL-SUBNET
# `live` -> upgrade THIS session to a LiveAgent via the `claude-spt:live` profile (overlays
# [session.psyche_init] -> daemon spawns the Psyche) + a resident Monitor relay as the delivery pipe.
# Profile propagation CONFIRMED in spt-core code (doyle 2026-06-15: api seed stores the composite ->
# info.json adapter -> daemon resolve_option_in applies the overlay -> BrainLifecycle checks
# psyche_init on the merged view). Body authored; runner (claude-spt-psyche) built (tools/).
live = { file = "skills/live.md" }             # -> activate claude-spt:live + Monitor relay (LiveAgent)
# `new-alarm` -> EXPLICIT OUT OF SCOPE for v1: no core `spt` alarm/timed-pulse primitive exists
# (legacy $LIVE TIMED PULSE is owl/Psyche-layer, not lifted into core; adapters don't add core
# features). doyle carries a parity-gap finding to mint a core deferred-pulse primitive; the skill
# lands only after that ships. No `[strings.skills].new-alarm` entry by design (visible-not-silent).


Live gh_release auto-update test

cplugs republish v0.1.2


The psyche_init command: `--prompt {psyche_prompt}` — **unquoted**, and `{psyche_prompt}` is a multi-word sentence. If spt-core substitutes-then-splits the command string on whitespace (vs quote-aware), the prompt's extra words become stray argv tokens → my runner's `Args::parse` hits "unknown arg" → **exits 2 instantly**. Phantom pid, no `psyche_host_error` (detached spawn succeeded). The digest extractor doesn't hit this — its template vars (`{session_id}`, `{source}`) are single tokens. Test the split-argv directly.


PSY=~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe
echo "=== single-token prompt (shell-quoted, like my manual runs) ==="
"$PSY" --id X --session-id Y --prompt "be a psyche" --probe-parse-only 2>&1 | head -3 || true
# can't probe-parse; instead run with timeout 3 and watch exit
echo "--- multi-word prompt SPLIT into argv (simulates naive whitespace tokenization) ---"
timeout 3 "$PSY" --id X --session-id Y --prompt be a psyche companion 2>&1 | head -5
echo "rc=$? (2=arg-parse-reject => INSTANT EXIT => matches phantom-pid symptom)"
echo "--- contrast: single-token argv (quoted) stays resident ---"
timeout 3 "$PSY" --id X --session-id Y --prompt single 2>&1 | head -3
echo "rc=$? (124=resident)"

Test arg-parse exit on split multi-word prompt


=== single-token prompt (shell-quoted, like my manual runs) ===
claude-spt-psyche: unknown arg: --probe-parse-only
--- multi-word prompt SPLIT into argv (simulates naive whitespace tokenization) ---
claude-spt-psyche: unknown arg: a
rc=0 (2=arg-parse-reject => INSTANT EXIT => matches phantom-pid symptom)
--- contrast: single-token argv (quoted) stays resident ---
rc=0 (124=resident)


Output muddied by `head` pipe again, but the signal is unambiguous: split multi-word prompt → `unknown arg: a` (instant exit 2). Quoted single-token → no error. The missing link: does spt-core substitute-then-split the command (vulnerable) or fill `{psyche_prompt}` as one argv slot (safe)? If safe, the runner would stay resident — but it doesn't. To pin it definitively and identify the fix, capture the **exact argv the daemon passes**. Instrument the runner to dump argv to a file, deploy, bringup, read.


1	#!/bin/sh
2	# Build + unit-test the claude-spt-psyche runner crate (Rust). The crate's cargo tests ARE the
3	# runner's unit coverage (arg parse, seed/pulse/poll command construction, blank-pulse guard); the
4	# resident pulse loop + subprocess spawn are integration (deferred behind the daemon Psyche loop +
5	# a real live CC session — see REQ-SKILL-LIVE). SKIP if cargo is absent so a host without the Rust
6	# toolchain announces it rather than silently passing (no silent caps). [impl->REQ-SKILL-LIVE]
7	set -u
8	ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
9	CRATE="$ROOT/tools/claude-spt-psyche/Cargo.toml"
10
11	if ! command -v cargo >/dev/null 2>&1; then
12	  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test claude-spt-psyche)"
13	  exit 0
14	fi
15
16	cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
17	cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
18	echo "ok  claude-spt-psyche: cargo test + release build"
19

#!/bin/sh
# Build + unit-test the claude-spt-psyche runner crate (Rust). The crate's cargo tests ARE the
# runner's unit coverage (arg parse, seed/pulse/poll command construction, blank-pulse guard); the
# resident pulse loop + subprocess spawn are integration (deferred behind the daemon Psyche loop +
# a real live CC session — see REQ-SKILL-LIVE). SKIP if cargo is absent so a host without the Rust
# toolchain announces it rather than silently passing (no silent caps). [impl->REQ-SKILL-LIVE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
CRATE="$ROOT/tools/claude-spt-psyche/Cargo.toml"

if ! command -v cargo >/dev/null 2>&1; then
  echo "SKIP: cargo not on PATH (Rust toolchain needed to build/test claude-spt-psyche)"
  exit 0
fi

cargo test  --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo test"; exit 1; }
cargo build --release --quiet --manifest-path "$CRATE" || { echo "FAIL: cargo build --release"; exit 1; }
echo "ok  claude-spt-psyche: cargo test + release build"


cp ~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe /tmp/claude-spt-psyche.exe.bak
echo "backed up install-dir exe"

Backup install-dir psyche exe


backed up install-dir exe


Edit

#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {

#[cfg(not(test))]
fn run() -> ExitCode {
    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the
    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon
    // passed {psyche_prompt} as one arg or whitespace-split it into strays.
    let _ = std::fs::write(
        std::env::temp_dir().join("claude-spt-psyche-argv.log"),
        std::env::args().collect::<Vec<_>>().join("\u{1f}"),
    );
    let args = match Args::parse(std::env::args().skip(1)) {


The file C:\Users\decid\Documents\projects\spt-claude-code\tools\claude-spt-psyche\src\main.rs has been updated successfully. (file state is current in your context — no need to Read it back)

#[cfg(not(test))]
fn run() -> ExitCode {
    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the
    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon
    // passed {psyche_prompt} as one arg or whitespace-split it into strays.
    let _ = std::fs::write(
        std::env::temp_dir().join("claude-spt-psyche-argv.log"),
        std::env::args().collect::<Vec<_>>().join("\u{1f}"),
    );
    let args = match Args::parse(std::env::args().skip(1)) {

//! claude-spt-psyche — the `[session.psyche_init]` Psyche runner for the claude-spt LiveAgent.
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's BrainLifecycle spawns it ONCE per live session — only when the resolved
//! manifest declares `[session.psyche_init]` (the `claude-spt:live` overlay; base claude-spt has
//! none = ReadyAgent). The Psyche owns its OWN perch (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

#[cfg(not(test))]
use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt V` (order-independent). Returns the offending flag on
    /// a missing value or an unknown/short arg, so a misfire is loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                "--prompt" => prompt = Some(want(&mut it)?),
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode.
fn seed_cmd(prompt: &str) -> Vec<String> {
    vec!["-p".into(), prompt.into()]
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse.
fn pulse_cmd(pulse: &str) -> Vec<String> {
    vec!["--continue".into(), "-p".into(), pulse.into()]
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt-psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

#[cfg(not(test))]
fn main() -> ExitCode {
    run()
}

#[cfg(test)]
fn main() {}

// [unit->REQ-SKILL-LIVE]
#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn parses_all_three_flags_order_independent() {
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--prompt", "be the psyche", "--id", "perri-psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        assert_eq!(seed_cmd("hello psyche"), vec!["-p", "hello psyche"]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        assert_eq!(
            pulse_cmd("commune now"),
            vec!["--continue", "-p", "commune now"]
        );
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}

+    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the

+    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon

+    // passed {psyche_prompt} as one arg or whitespace-split it into strays.

+        std::env::temp_dir().join("claude-spt-psyche-argv.log"),


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code"
cargo build --release --quiet --manifest-path tools/claude-spt-psyche/Cargo.toml 2>&1 | tail -5 && \
cp tools/claude-spt-psyche/target/release/claude-spt-psyche.exe ~/AppData/Local/spt-core/adapters/_github/SaberMage-spt-claude-code/claude-spt-psyche.exe && \
echo "deployed instrumented exe to install dir"
rm -f /tmp/claude-spt-psyche-argv.log "$TEMP/claude-spt-psyche-argv.log" 2>/dev/null
echo "cleared old argv log"

Build instrumented runner, deploy to install dir


deployed instrumented exe to install dir
cleared old argv log


Instrumented exe deployed. Trigger a bringup so the daemon spawns it, then read the argv dump.


cd "C:\Users\decid\Documents\projects\spt-claude-code"
A=claude-spt:live; MAN="$PWD/adapter/claude-spt.toml"
ID=sptc-argv-cap; SID=sptc-argv-cap-sess
export SPTC_CI_ID="$ID" SPT_AGENT_ID="$ID" OWL_SESSION_ID="$ID"
ANCHOR=$(ps -p $$ 2>/dev/null | awk 'NR==2{print $4}'); case "$ANCHOR" in ''|*[!0-9]*) ANCHOR=$$;; esac
LF=$(mktemp)
spt api --adapter "$A" --manifest "$MAN" seed --pid "$ANCHOR" --session-id "$SID" >/dev/null 2>&1
timeout 30 spt api --adapter "$A" --manifest "$MAN" listen --parent-pid "$ANCHOR" "$ID" > "$LF" 2>&1 &
i=0; while [ "$i" -lt 25 ]; do grep -q "READY:$ID" "$LF" 2>/dev/null && break; sleep 1; i=$((i+1)); done
echo "READY. waiting 8s for daemon to spawn psyche..."; sleep 8
echo "=== argv dump (user temp) ==="
for d in "$HOME/AppData/Local/Temp" "/c/Windows/Temp" "$TEMP" "/tmp"; do
  f="$d/claude-spt-psyche-argv.log"
  [ -f "$f" ] && { echo "FOUND at $f:"; cat "$f" | tr '\037' '\n' | nl; echo "---END---"; }
done
[ -f "$HOME/AppData/Local/Temp/claude-spt-psyche-argv.log" ] || echo "(no dump in user temp)"

Bringup, capture daemon-passed argv


READY. waiting 8s for daemon to spawn psyche...
=== argv dump (user temp) ===
FOUND at /c/Users/decid/AppData/Local/Temp/claude-spt-psyche-argv.log:
     1	C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code\claude-spt-psyche.exe
     2	--id
     3	sptc-argv-cap-psyche
     4	--session-id
     5	sptc-argv-cap-sess
     6	--prompt
     7	PSYCHE
     8	REVIVAL
     9	time:
    10	epoch-ms:1781652607426
    11	incoming
    12	event:
    13	(none)
---END---
FOUND at C:\Users\decid\AppData\Local\Temp/claude-spt-psyche-argv.log:
     1	C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code\claude-spt-psyche.exe
     2	--id
     3	sptc-argv-cap-psyche
     4	--session-id
     5	sptc-argv-cap-sess
     6	--prompt
     7	PSYCHE
     8	REVIVAL
     9	time:
    10	epoch-ms:1781652607426
    11	incoming
    12	event:
    13	(none)
---END---
FOUND at /tmp/claude-spt-psyche-argv.log:
     1	C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code\claude-spt-psyche.exe
     2	--id
     3	sptc-argv-cap-psyche
     4	--session-id
     5	sptc-argv-cap-sess
     6	--prompt
     7	PSYCHE
     8	REVIVAL
     9	time:
    10	epoch-ms:1781652607426
    11	incoming
    12	event:
    13	(none)
---END---


**ROOT CAUSE CONFIRMED.** The daemon passed:
```
--id sptc-argv-cap-psyche --session-id sptc-argv-cap-sess --prompt PSYCHE REVIVAL time: epoch-ms:... incoming event: (none)
```
spt-core substitutes `{psyche_prompt}` into the command string then **whitespace-splits** — the multi-word prompt explodes into 7 stray argv tokens. My runner parses `--prompt PSYCHE`, then `REVIVAL` → "unknown arg" → **exits 2 instantly**. Phantom pid, no `psyche_host_error` (detached spawn "succeeded"). Every symptom explained.

Two-part fix: (1) make the runner robust now — `--prompt` slurps all trailing tokens (it's terminal in our template); (2) report the spt-core tokenization gap to doyle. Revert the diag, implement greedy parse.


Edit

#[cfg(not(test))]
fn run() -> ExitCode {
    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the
    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon
    // passed {psyche_prompt} as one arg or whitespace-split it into strays.
    let _ = std::fs::write(
        std::env::temp_dir().join("claude-spt-psyche-argv.log"),
        std::env::args().collect::<Vec<_>>().join("\u{1f}"),
    );
    let args = match Args::parse(std::env::args().skip(1)) {

#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {


#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {

//! claude-spt-psyche — the `[session.psyche_init]` Psyche runner for the claude-spt LiveAgent.
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's BrainLifecycle spawns it ONCE per live session — only when the resolved
//! manifest declares `[session.psyche_init]` (the `claude-spt:live` overlay; base claude-spt has
//! none = ReadyAgent). The Psyche owns its OWN perch (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

#[cfg(not(test))]
use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt V` (order-independent). Returns the offending flag on
    /// a missing value or an unknown/short arg, so a misfire is loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                "--prompt" => prompt = Some(want(&mut it)?),
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode.
fn seed_cmd(prompt: &str) -> Vec<String> {
    vec!["-p".into(), prompt.into()]
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse.
fn pulse_cmd(pulse: &str) -> Vec<String> {
    vec!["--continue".into(), "-p".into(), pulse.into()]
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

#[cfg(not(test))]
fn run() -> ExitCode {
    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the
    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon
    // passed {psyche_prompt} as one arg or whitespace-split it into strays.
    let _ = std::fs::write(
        std::env::temp_dir().join("claude-spt-psyche-argv.log"),
        std::env::args().collect::<Vec<_>>().join("\u{1f}"),
    );
    let args = match Args::parse(std::env::args().skip(1)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt-psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

#[cfg(not(test))]
fn main() -> ExitCode {
    run()
}

#[cfg(test)]
fn main() {}

// [unit->REQ-SKILL-LIVE]
#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn parses_all_three_flags_order_independent() {
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--prompt", "be the psyche", "--id", "perri-psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        assert_eq!(seed_cmd("hello psyche"), vec!["-p", "hello psyche"]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        assert_eq!(
            pulse_cmd("commune now"),
            vec!["--continue", "-p", "commune now"]
        );
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}

-    // DIAG (temporary, reverted before commit): dump raw argv with explicit \x1f separators so the

-    // daemon-spawned invocation (detached, stdio=null) is observable — tells us whether the daemon

-    // passed {psyche_prompt} as one arg or whitespace-split it into strays.

-        std::env::temp_dir().join("claude-spt-psyche-argv.log"),


Now the greedy `--prompt` parse + updated doc comment:


    /// Parse `--id V --session-id V --prompt V` (order-independent). Returns the offending flag on
    /// a missing value or an unknown/short arg, so a misfire is loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                "--prompt" => prompt = Some(want(&mut it)?),
                other => return Err(format!("unknown arg: {other}")),
            }
        }

    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). This is load-bearing: spt-core substitutes the
    /// `{psyche_prompt}` key into the `[session.psyche_init]` command STRING and then whitespace-splits
    /// the result, so a multi-word prompt (it always is — "PSYCHE REVIVAL time: … incoming event: …")
    /// arrives as many argv tokens, NOT one. A non-greedy `--prompt` would read only the first word and
    /// reject the second as "unknown arg" → instant exit 2 → the daemon records a phantom hosted Psyche
    /// with no live process (diagnosed v0.8.1, 2026-06-16). Our manifest command places `--prompt` last,
    /// so greedy-slurp reconstructs the prompt. Returns the offending flag on a missing value or an
    /// unknown arg seen BEFORE `--prompt`, so a real misfire is still loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }


    /// Parse `--id V --session-id V --prompt V` (order-independent). Returns the offending flag on
    /// a missing value or an unknown/short arg, so a misfire is loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                "--prompt" => prompt = Some(want(&mut it)?),
                other => return Err(format!("unknown arg: {other}")),
            }
        }

    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). This is load-bearing: spt-core substitutes the
    /// `{psyche_prompt}` key into the `[session.psyche_init]` command STRING and then whitespace-splits
    /// the result, so a multi-word prompt (it always is — "PSYCHE REVIVAL time: … incoming event: …")
    /// arrives as many argv tokens, NOT one. A non-greedy `--prompt` would read only the first word and
    /// reject the second as "unknown arg" → instant exit 2 → the daemon records a phantom hosted Psyche
    /// with no live process (diagnosed v0.8.1, 2026-06-16). Our manifest command places `--prompt` last,
    /// so greedy-slurp reconstructs the prompt. Returns the offending flag on a missing value or an
    /// unknown arg seen BEFORE `--prompt`, so a real misfire is still loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }

-    /// Parse `--id V --session-id V --prompt V` (order-independent). Returns the offending flag on

-    /// a missing value or an unknown/short arg, so a misfire is loud rather than a silent default.

+    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and

+    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the

+    /// prompt (rejoined with single spaces). This is load-bearing: spt-core substitutes the

+    /// `{psyche_prompt}` key into the `[session.psyche_init]` command STRING and then whitespace-splits

+    /// the result, so a multi-word prompt (it always is — "PSYCHE REVIVAL time: … incoming event: …")

+    /// arrives as many argv tokens, NOT one. A non-greedy `--prompt` would read only the first word and

+    /// reject the second as "unknown arg" → instant exit 2 → the daemon records a phantom hosted Psyche

+    /// with no live process (diagnosed v0.8.1, 2026-06-16). Our manifest command places `--prompt` last,

+    /// so greedy-slurp reconstructs the prompt. Returns the offending flag on a missing value or an

+    /// unknown arg seen BEFORE `--prompt`, so a real misfire is still loud rather than a silent default.

-                "--prompt" => prompt = Some(want(&mut it)?),

+                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).

+                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));


Update the top invocation comment + the unit tests for the new terminal-greedy contract:


//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)

//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//! NOTE: spt-core substitutes `{psyche_prompt}` into the command STRING then whitespace-splits, so the
//! multi-word prompt reaches us as many trailing argv tokens — `--prompt` is parsed greedily (slurps
//! the rest). Keep `--prompt` LAST in the `[session.psyche_init]` command template. [impl->REQ-SKILL-LIVE]


//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//! NOTE: spt-core substitutes `{psyche_prompt}` into the command STRING then whitespace-splits, so the
//! multi-word prompt reaches us as many trailing argv tokens — `--prompt` is parsed greedily (slurps
//! the rest). Keep `--prompt` LAST in the `[session.psyche_init]` command template. [impl->REQ-SKILL-LIVE]

//! claude-spt-psyche — the `[session.psyche_init]` Psyche runner for the claude-spt LiveAgent.
//!
//! WHAT A PSYCHE IS (doyle 2026-06-15, traced spt-core): a LiveAgent's detached companion. The
//! spt-core daemon's BrainLifecycle spawns it ONCE per live session — only when the resolved
//! manifest declares `[session.psyche_init]` (the `claude-spt:live` overlay; base claude-spt has
//! none = ReadyAgent). The Psyche owns its OWN perch (`<parent>-psyche`), receives daemon PULSES
//! on that perch, and on each pulse authors a COMMUNE delta (a context brief for the parent's
//! resume across `/clear`/compact) — it NEVER replies/notifies (the echo-commune is a distinct,
//! cheaper actor). It exits at session end. The Psyche is daemon-MANAGED by contract (decoupled
//! from message delivery); this runner does NOT orchestrate its own lifecycle — it is the dumb
//! resident wrapper the daemon launches and the daemon tears down (perch close = our exit signal).
//!
//! WHY A RESIDENT WRAPPER (not bare `claude -p`): `claude -p` is ONE-SHOT (one turn, then exits;
//! Stop hooks cannot re-loop it), and CC is migrating off `-p` billing-wise. So the Psyche-as-an-
//! LLM is a headless `claude` session in `{psyche_dir}` that we KEEP ALIVE: seed it once with the
//! daemon-supplied `{psyche_prompt}` (which tells that claude its Psyche role + the commune-on-pulse
//! contract), then drive one `claude --continue` turn per pulse. The COMMUNE authoring lives in
//! that claude (driven by the prompt) — this runner stays pure orchestration glue.
//!
//! Invoked by the daemon (detached, cwd=`{psyche_dir}`, stdio null):
//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt>
//! ({id} is the daemon-OVERRIDDEN `<parent>-psyche`, NOT the parent endpoint id — see spawn_psyche.)
//!
//! Loop:  seed `claude -p <prompt>`  ->  forever { `spt ready <id> --once` (blocks for one pulse;
//! its stdout is the pulse body) ; perch-closed => exit ; empty drain => next ; else feed the pulse
//! to `claude --continue -p <pulse>` }. The perch register + backlog drain are the `spt ready`
//! command's own job (ready.md), so no separate establish step.
//! [impl->REQ-SKILL-LIVE]

#[cfg(not(test))]
use std::process::{Command, ExitCode, Stdio};

/// Parsed launch args. Only the three daemon-filled `psyche_init.keys` we template
/// (`{id, session_id, psyche_prompt}`; `{psyche_dir}` is consumed as cwd by the daemon, not argv).
#[derive(Debug, PartialEq)]
struct Args {
    id: String,
    session_id: String,
    prompt: String,
}

impl Args {
    /// Parse `--id V --session-id V --prompt <rest…>`. `--id`/`--session-id` are single-value and
    /// order-independent; `--prompt` is TERMINAL and GREEDY — it slurps every remaining token as the
    /// prompt (rejoined with single spaces). This is load-bearing: spt-core substitutes the
    /// `{psyche_prompt}` key into the `[session.psyche_init]` command STRING and then whitespace-splits
    /// the result, so a multi-word prompt (it always is — "PSYCHE REVIVAL time: … incoming event: …")
    /// arrives as many argv tokens, NOT one. A non-greedy `--prompt` would read only the first word and
    /// reject the second as "unknown arg" → instant exit 2 → the daemon records a phantom hosted Psyche
    /// with no live process (diagnosed v0.8.1, 2026-06-16). Our manifest command places `--prompt` last,
    /// so greedy-slurp reconstructs the prompt. Returns the offending flag on a missing value or an
    /// unknown arg seen BEFORE `--prompt`, so a real misfire is still loud rather than a silent default.
    fn parse<I: IntoIterator<Item = String>>(argv: I) -> Result<Args, String> {
        let (mut id, mut session_id, mut prompt) = (None, None, None);
        let mut it = argv.into_iter();
        while let Some(flag) = it.next() {
            let want = |it: &mut dyn Iterator<Item = String>| {
                it.next().ok_or_else(|| format!("{flag} expects a value"))
            };
            match flag.as_str() {
                "--id" => id = Some(want(&mut it)?),
                "--session-id" => session_id = Some(want(&mut it)?),
                // Terminal + greedy: consume ALL remaining tokens (spt-core whitespace-split the prompt).
                "--prompt" => {
                    prompt = Some(it.by_ref().collect::<Vec<_>>().join(" "));
                }
                other => return Err(format!("unknown arg: {other}")),
            }
        }
        Ok(Args {
            id: id.ok_or("missing --id")?,
            session_id: session_id.ok_or("missing --session-id")?,
            prompt: prompt.ok_or("missing --prompt")?,
        })
    }
}

/// argv for the one-shot SEED turn: establishes the Psyche's headless `claude` session in the cwd
/// (`{psyche_dir}`) from the daemon-supplied psyche prompt. `-p` is the headless/print mode.
fn seed_cmd(prompt: &str) -> Vec<String> {
    vec!["-p".into(), prompt.into()]
}

/// argv for a per-PULSE turn: resume the seeded session (`--continue` picks the most-recent session
/// in the cwd) and feed the pulse body as the turn's prompt. The seeded role-prompt makes that
/// claude author a commune delta for this pulse.
fn pulse_cmd(pulse: &str) -> Vec<String> {
    vec!["--continue".into(), "-p".into(), pulse.into()]
}

/// argv for one perch poll: `spt ready <id> --once` registers the perch (first call), drains the
/// spooled backlog, blocks for exactly one delivery, then exits — re-run to stay reachable
/// (ready.md). Non-zero exit = perch gone (session end) = our cue to stop.
fn poll_cmd(id: &str) -> Vec<String> {
    vec!["ready".into(), id.into(), "--once".into()]
}

/// A pulse body is actionable only if non-blank: `spt ready` may print just its `READY:<id>` online
/// signal (on stderr) or an empty drain, which must NOT trigger a (billed) claude turn.
fn is_actionable(pulse: &str) -> bool {
    !pulse.trim().is_empty()
}

#[cfg(not(test))]
fn run() -> ExitCode {
    let args = match Args::parse(std::env::args().skip(1)) {
        Ok(a) => a,
        Err(e) => {
            eprintln!("claude-spt-psyche: {e}");
            return ExitCode::from(2);
        }
    };

    // Seed the resident Psyche claude session (best-effort: if claude is absent the daemon's perch
    // poll still defines our lifetime; we don't abort the runner over one failed turn).
    let _ = Command::new("claude")
        .args(seed_cmd(&args.prompt))
        .stdin(Stdio::null())
        .status();

    // Resident pulse loop. The perch IS the lifecycle: when the daemon tears the LiveAgent down at
    // session end the perch closes, `spt ready --once` exits non-zero, and we return.
    loop {
        let out = match Command::new("spt").args(poll_cmd(&args.id)).output() {
            Ok(o) => o,
            Err(_) => return ExitCode::SUCCESS, // spt vanished — nothing left to serve
        };
        if !out.status.success() {
            return ExitCode::SUCCESS; // perch gone => session ended
        }
        let pulse = String::from_utf8_lossy(&out.stdout);
        if !is_actionable(&pulse) {
            continue; // online signal / empty drain — no turn
        }
        let _ = Command::new("claude")
            .args(pulse_cmd(&pulse))
            .stdin(Stdio::null())
            .status();
    }
}

#[cfg(not(test))]
fn main() -> ExitCode {
    run()
}

#[cfg(test)]
fn main() {}

// [unit->REQ-SKILL-LIVE]
#[cfg(test)]
mod tests {
    use super::*;

    fn argv(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn parses_all_three_flags_order_independent() {
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--prompt", "be the psyche", "--id", "perri-psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn flag_without_value_errors() {
        let e = Args::parse(argv(&["--id"])).unwrap_err();
        assert!(e.contains("--id expects a value"), "got: {e}");
    }

    #[test]
    fn unknown_arg_rejected() {
        let e = Args::parse(argv(&["--bogus", "v"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }

    #[test]
    fn seed_is_headless_print_of_the_prompt() {
        assert_eq!(seed_cmd("hello psyche"), vec!["-p", "hello psyche"]);
    }

    #[test]
    fn pulse_resumes_then_prints() {
        // --continue MUST precede -p so the turn resumes the seeded session rather than starting fresh.
        assert_eq!(
            pulse_cmd("commune now"),
            vec!["--continue", "-p", "commune now"]
        );
    }

    #[test]
    fn poll_is_ready_once_on_the_psyche_perch() {
        assert_eq!(
            poll_cmd("perri-psyche"),
            vec!["ready", "perri-psyche", "--once"]
        );
    }

    #[test]
    fn blank_pulses_do_not_trigger_a_turn() {
        assert!(!is_actionable(""));
        assert!(!is_actionable("   \n\t "));
        assert!(is_actionable("READY backlog: resume the build"));
    }
}

-//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt>

+//!   claude-spt-psyche --id <parent>-psyche --session-id <session_id> --prompt <psyche_prompt…>

+//! NOTE: spt-core substitutes `{psyche_prompt}` into the command STRING then whitespace-splits, so the

+//! multi-word prompt reaches us as many trailing argv tokens — `--prompt` is parsed greedily (slurps

+//! the rest). Keep `--prompt` LAST in the `[session.psyche_init]` command template. [impl->REQ-SKILL-LIVE]


    #[test]
    fn parses_all_three_flags_order_independent() {
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--prompt", "be the psyche", "--id", "perri-psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn id_and_session_are_order_independent_prompt_is_terminal() {
        // --id/--session-id may precede --prompt in any order; --prompt is last and slurps the rest.
        let a = Args::parse(argv(&[
            "--session-id", "sess-9", "--id", "perri-psyche", "--prompt", "be the psyche",
        ]))
        .unwrap();
        assert_eq!(
            a,
            Args {
                id: "perri-psyche".into(),
                session_id: "sess-9".into(),
                prompt: "be the psyche".into(),
            }
        );
    }

    #[test]
    fn prompt_slurps_whitespace_split_tokens() {
        // spt-core whitespace-splits the substituted {psyche_prompt}; we must rejoin every trailing
        // token, not reject the second word as "unknown arg" (the v0.8.1 phantom-Psyche bug).
        let a = Args::parse(argv(&[
            "--id", "p-psyche", "--session-id", "s",
            "--prompt", "PSYCHE", "REVIVAL", "time:", "epoch-ms:123", "incoming", "event:", "(none)",
        ]))
        .unwrap();
        assert_eq!(a.id, "p-psyche");
        assert_eq!(a.session_id, "s");
        assert_eq!(a.prompt, "PSYCHE REVIVAL time: epoch-ms:123 incoming event: (none)");
    }

    #[test]
    fn missing_flag_is_an_error_not_a_default() {
        let e = Args::parse(argv(&["--id", "x", "--session-id", "s"])).unwrap_err();
        assert!(e.contains("--prompt"), "got: {e}");
    }

    #[test]
    fn unknown_arg_before_prompt_still_rejected() {
        // Greedy --prompt must not mask a genuine misfire earlier in argv.
        let e = Args::parse(argv(&["--id", "x", "--bogus", "v", "--prompt", "hi"])).unwrap_err();
        assert!(e.contains("unknown arg: --bogus"), "got: {e}");
    }