1	# Adapter patterns & pitfalls
2
3	The [integration checklist](integration-checklist.md) tells you *which* surfaces
4	to wire. This page is the field guide: the patterns that decide whether an
5	adapter merely registers or runs like a native part of the harness — the design
6	rules, the lessons that save you a debugging session, and the cheapest ways to
7	prove each piece on the live binary.
8
9	Everything here is behaviour of the **shipped public surface** — the `spt`
10	binary, the [manifest](manifest.md), and the [`spt api`](api.md) commands,
11	verified against a live binary. It is harness-agnostic; where one harness's quirk
12	is the clearest illustration it is called out as such, and the pattern
13	generalizes to any harness with the same shape.
14
15	## The one rule: manifests are static, logic lives in binaries
16
17	If you internalize a single thing, make it this.
18
19	- **Manifest fields are static templates spt-core fills.** A field is a fixed
20	  template: spt-core substitutes `{key}` placeholders from a fixed
21	  [catalog](manifest.md#substitution-keys) (`{session_id}`, `{parent_pid}`,
22	  `{adapter_name}`, `{id}`, the digest/psyche keys), and `~` expands to home.
23	  That is the whole of a template's power.
24	- **Anything that depends on runtime state belongs in a binary the manifest
25	  points at** — the `[digest]` extractor, a `[session.*]` runner. Reading an env
26	  var, branching on runtime state, or computing a value is *logic*, so it lives
27	  in a binary. If your harness can move its own state directory at runtime, for
28	  example, treat the manifest `source` as a *fallback* root and have the binary
29	  it points at resolve the real location itself.
30	- **A `.toml`-only leaf carries no code of its own**, so verify it by registering
31	  and resolving it on the live binary (below), and put anything you want covered
32	  by real tests into a binary (an extractor or runner).
33
34	Hold this rule and most of the surface falls into place: the manifest is the
35	*declaration*, your binaries are the *behaviour*.
36
37	## The adapter lives in the registry
38
39	An adapter — its manifest, profiles, `[strings]`, the `[digest]` extractor, any
40	runner binaries — is registered with **`spt adapter add <dir>`** into the
41	node-local adapter registry. The version recorded there (`spt adapter list`) is
42	the **version-of-truth** for what the adapter does. That is the entire, universal
43	delivery mechanism: every spt adapter ships this way, and registration is where
44	spt-core validates it (see [the second gate](#validate-against-the-live-binary)).
45
46	If your harness *also* has a plugin or marketplace channel (so casual users can
47	one-click install it), that is a separate distribution choice on top. When you go
48	that route, let the **registry** carry the binary, manifest, and runtime state,
49	and version the plugin independently of the manifest/binary.
50
51	## Profiles are sparse leaf-replace overlays
52
53	A profile is selected as the composite `<adapter>:<profile>` and
54	**leaf-replaces only the leaves you declare** — everything else inherits from
55	base. Override exactly what differs:
56
57	- `[profiles.<name>.session.self].command` — retarget the bringup command (for
58	  example, wrap the launch in another binary).
59	- `[profiles.<name>.digest].<key>` — widen one digest knob.
60	- `[profiles.<name>.session.psyche_init]` — add the [live-agent
61	  seam](#the-live-agent-companion-seam); its presence on the merged view is what
62	  flips an endpoint to a live agent.
63
64	**Make an overlay observable.** Also leaf-replace one `[strings]` key (say a
65	label) in the profile. Then `spt adapter get-string <adapter>:<profile> <key>`
66	differs from the base value — and that diff is your proof the overlay resolved.
67	It is the cheapest profile acceptance assertion there is.
68
69	A profile that wraps the launch in another binary works when that binary is a
70	drop-in for the base harness binary on the same argv and passes inherited env
71	through unchanged. Routing a session through a launcher wrapper (a model or
72	billing multiplexer, say) is exactly this: replace the `session.self` command and
73	let the injected endpoint-id env ride through untouched.
74
75	## Wiring hooks: you own the harness side
76
77	spt-core supplies the harness-**independent** `spt api` primitives and their I/O
78	format. *You* author all harness-specific wiring: spt-core supplies the
79	primitives, and your adapter hand-writes its hook config to shell out to
80	`spt api`. A mapping that works on the public surface, in terms any harness can
81	translate to its own events:
82
83	| When the harness… | …fire | Why |
84	| --- | --- | --- |
85	| starts a session | `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}` | Seed the endpoint — keep this fast and non-blocking. |
86	| submits a user turn | `api poll {session_id}` | Drain the inbox to stdout (plus any keyword hints). |
87	| goes idle / busy | `api state idle` / `api state busy` | Honest activity; spt-core treats your explicit `api state` calls as the source of truth. |
88	| ends the session | `api session-end {session_id}` (or `api shutdown <id>` for graceful signoff) | Teardown that preserves the spool + history. |
89	| spawns / ends a sub-agent | `api worker-start` / `api worker-stop` | Nested short-lived workers. |
90
91	Two structural rules sit under that table:
92
93	- **Run the blocking listen/poll loop from a skill the user invokes.** Seed on
94	  start so bringup stays fast, and let an explicit `/ready`-style skill own the
95	  blocking stream.
96	- **Message delivery is stdout framing.** `api poll` emits the self-delimiting
97	  envelope `<EVENT type="msg" from="<sender>">body</EVENT>` (the live listener
98	  stream uses the same shape). Multi-message drains split cleanly on `</EVENT>`.
99	  Decode a body by splitting on `<br>` → newline, then HTML-unescaping
100	  `< > "` and `&` **last**. Route that stdout into your harness's
101	  injection channel — that routing is adapter glue.
102
103	### Get these right in the hook layer
104
105	A few patterns here save you a debugging session — wire them deliberately.
106
107	- **Pre-empt an injection channel's size cap.** If your harness caps the size of
108	  an injected blob (truncating it, or spilling it to a file and evicting it from
109	  the context the agent actually reads), cap the combined hook output
110	  adapter-side: under the limit, pass the output through verbatim; over it, spill
111	  the **full** text to an agent-readable file and inject a short pointer. Always
112	  cut on an `</EVENT>` boundary, so every envelope stays whole and every message
113	  survives a large drain.
114	- **Inject a skill body *before* the perch gate, and gate only the message
115	  drain.** When the same prompt hook both injects a requested skill's
116	  instructions and drains messages, run the skill-body injection first — that
117	  keeps skills like "who am I" or "set me up" working for a new user, since they
118	  are valid while the perch is still being readied. Match the skill token as a
119	  leading token, so only an actual invocation fires (prose that merely mentions
120	  it stays inert).
121	- **Make the setup/installer skill self-contained in its stub.** It runs
122	  precisely when the binary may be absent (installing it is the job), so carry
123	  its operative steps in the harness-native stub itself — the floor that always
124	  works — and let any file-backed body mirror them for the binary-present repair
125	  path. The one skill that most needs delivery is the one delivery reaches last,
126	  so give it a stub that stands alone.
127	- **Read hook inputs from stdin.** A hook receives its data (the prompt, the
128	  session id) as a JSON object on **stdin** — parse that, which keeps a
129	  `/`-leading value (a `/<skill>` token, an absolute path) intact. Under
130	  Git-Bash/MSYS on Windows, an argument beginning with `/` is rewritten to a
131	  Windows path before your command sees it (a `/foo:send` token can arrive as
132	  `C:/Program Files/Git/send`), so stdin is the transport that preserves it. If a
133	  command must take such an argument, guard it (`MSYS_NO_PATHCONV=1`, or a
134	  file/stdin transport). It is the same class as the UTF-8-stdout trap below —
135	  choose the transport that carries the data faithfully.
136
137	## `[strings]`: keep the manifest thin, point at the live binary
138
139	A `[strings]` value is either an inline string or a **file pointer**
140	(`key = { file = "relative/path" }`), resolved lazily by `spt adapter get-string`
141	to the file's contents — so live edits reflect without re-registering. Keep
142	pointer files inside the `strings/` dir; the add enforces that containment. Use
143	file pointers to keep skill-instruction bodies out of the manifest.
144
145	When a skill body needs to describe the spt surface, point it at the binary's own
146	self-documentation, so the guidance stays current with the shipped binary — two
147	always-current tiers:
148
149	- `spt how-to <topic>` is the task-oriented agent-guidance surface, covering
150	  **selected** topics, each a canonical write-up of verbs, flags, and result
151	  codes. Treat it as the curated tier: for a verb it covers, read the topic; for
152	  any other verb, probe and fall through (an undocumented topic returns
153	  `NO_SUCH_TOPIC:<topic>`).
154	- For any verb, **`spt <verb> --help` is the always-present source-of-truth** —
155	  it tracks the shipped binary. A skill body that says "the verb list is
156	  `spt <noun> --help` — match the user's intent to a verb" stays correct across
157	  releases.
158
159	Either tier stays current with the binary, and a skill body that points at them
160	stays correct across releases. They are also the fastest way to learn the surface
161	while authoring — it self-documents.
162
163	## `[digest]`: the transcript→record extractor
164
165	The `[digest]` seam maps your harness's native transcript into spt-core's
166	digest-record contract. The contract beyond the schema:
167
168	- **Name where it reads** — either `source` or a `[history].locate_template`.
169	  `spt adapter add` requires this even though the JSON schema alone would accept a
170	  bare `extractor`; the cross-field rule surfaces at registration, so validate
171	  against the live binary.
172	- **Treat `--in {source}` as a root.** The extractor is invoked
173	  `--session {session_id} --in {source}` and locates `<session_id>`'s transcript
174	  *within* that root — your harness's internal subdir scheme is yours to resolve,
175	  and spt-core keeps the key catalog harness-agnostic. Handle both shapes: `--in`
176	  a directory (locate the session) and `--in` a direct file (the
177	  `digest-proof --sample` path).
178	- **Resolve a runtime-relocated state tree in the binary.** When a runtime value
179	  (an env var, an isolated profile) moves the real transcript tree, have the
180	  extractor prefer that value on its directory branch, with the manifest `source`
181	  as the fallback root. That resolution is logic, so it lives in the binary — the
182	  headline rule in miniature.
183	- **Emit raw records as UTF-8.** Output one NDJSON line per record
184	  (`{role ∈ input|agent|tool, text?, tool?, ts?}`) and leave presentation to
185	  spt-core's renderer (`window_turns`, arg truncation, sprint collapse). Pin
186	  stdout to **UTF-8** so non-ASCII (em-dashes, smart quotes) round-trips — spt-core
187	  reads the stream as UTF-8. (Native-UTF-8 languages get this for free, which is
188	  part of why this seam is a binary.)
189
190	Prove the whole path with `spt adapter digest-proof <adapter> --sample <file>`
191	(below).
192
193	## The bringup / launcher seam
194
195	`[session.self].command` is the spt-hosted bringup template — spt-core spawns it
196	into a broker PTY. For a harness with no native session-id flag, mint the id
197	internally and pass the endpoint id via an injected env var
198	(`[env.<VAR>]` with `direction = "inject"`, `value = "{id}"`); the start hook
199	reads that env and self-registers with `api bind <id>`.
200
201	That bind is **intrinsically authenticated**: for a broker-spawned session the
202	broker parentage is the proof, so `api bind <id> --set-session-id <discovered>`
203	alone establishes the association, and later mutating calls prove themselves with
204	the session id the bind recorded. (The flip side shows up in
205	[testing](#testing-against-a-real-harness-isolate-identity): the framework keys
206	association on *identity*, so identity is the thing you isolate.)
207
208	`adapter.shortcut_basename` brands the generated launcher shortcut
209	(`<basename>-<id>`) and is decoupled from the adapter name.
210
211	## The live-agent (companion) seam
212
213	An endpoint is a **live agent** exactly when its *resolved* manifest declares
214	`[session.psyche_init]` — declaring that section is the single go-live signal. A
215	base manifest is a ready agent; a profile overlay that adds the section makes a
216	live agent. spt-core checks this on the **merged** view, so a profile selected at
217	**seed time** (`spt api --adapter <adapter>:<profile> seed`) drives the spawn
218	decision all the way through — the bound profile governs the full runtime
219	lifecycle, beyond bringup argv.
220
221	- `psyche_init` fills exactly four keys: `{id, session_id, psyche_dir,
222	  psyche_prompt}`. spt-core **overrides** `{id}` to `<parent>-psyche` before
223	  substitution, so the companion gets its own derived perch id. (The
224	  resume/preload key `{psyche_context}` is a separate seam; a first spawn fills
225	  it on resume.)
226	- The companion is launched **detached and fire-and-forget**: `detach = true`,
227	  `cwd = "{psyche_dir}"`, stdio null, handle dropped, **unsupervised**. Liveness
228	  stays daemon-authoritative through the companion's perch. It owns the
229	  `<parent>-psyche` perch, communicates over its perch and commune file-drops,
230	  and exits at session end.
231	- **The companion runner is yours to build; its lifecycle is the daemon's.**
232	  `psyche_init.command` is adapter-authored and opaque to spt-core. Declare the
233	  seam and build the runner, and let the daemon own spawn and teardown (a
234	  graceful `endpoint shutdown` tears the companion down with the perch).
235	- **If your harness's headless mode runs one turn per invocation**, make the
236	  runner a small **resident wrapper** so it persists across pulses: seed the
237	  companion once from `{psyche_prompt}`, then drive one resume-turn per perch
238	  pulse, with the companion authoring commune drops. Build it like the `[digest]`
239	  extractor — a compiled, dependency-light binary the daemon can exec bare on any
240	  platform.
241
242	## Lifecycle continuity is file-drops
243
244	Commune and signoff are delivered as **file-drops** by design. The agent writes
245	`<endpoint_id>-commune.md` (delta context) or `<endpoint_id>-signoff.md` (final
246	save) into the manifest-declared `[session].commune_dir` / `signoff_dir`;
247	spt-core's daemon watcher ingests it and deletes it (the daemon is the single
248	writer). The **filenames are contract-fixed** and the directory is
249	adapter-declared, so wire the directory watch and read the contract filename.
250	This is the single biggest continuity win, so it is worth getting exactly right.
251
252	## Testing against a real harness: isolate identity
253
254	The surest way to prove your hook wiring fires is an acceptance test that
255	**spawns a real harness session as the system-under-test**. Doing so meets a
256	framework property you design around:
257
258	- A perch's identity is **resolved from the environment** (the same vars
259	  `spt whoami` reads), and perches are **name-keyed, last-establish-wins**. The
260	  most recent session to establish a perch under a given identity holds it,
261	  taking the active poll/listen stream with it.
262	- So a spawned test session that loads your adapter (whose start hook seeds and
263	  binds a perch) under the identity of the agent running the tests would take
264	  that agent's perch. **Identity isolation is the guard.**
265	- Give every spawned system-under-test a disposable identity distinct from any
266	  live agent — override **both** identity env vars before the spawn to a
267	  throwaway `<adapter>-ci-<n>`, so the nested session and the operator's perch
268	  coexist cleanly. Identity is the key, so isolating identity is the whole guard.
269	- Keep the orchestration deterministic and **assert on a hook side-effect** — a
270	  marker or digest file, or `spt` state — the deterministic signal. Keep the
271	  harness as the system-under-test and let its side effects be your assertions.
272
273	## Validate against the live binary
274
275	Treat registration as the **second gate**: beyond JSON-schema validity,
276	`spt adapter add` runs cross-field checks that go past what the schema expresses
277	(the `[digest]` source rule is one). Build for it:
278
279	- A **registration integration check**: `adapter add` → `adapter list` (assert
280	  the adapter and each shipped profile composite resolves) → `get-string` (the
281	  base value, each overlay diff, and each file-backed pointer resolve to a body)
282	  → a soft `adapter remove` (leaving the registry clean). Gate it behind an
283	  opt-in env flag and a minimum `spt` version, since it mutates the node-local
284	  registry.
285	- Two author-time tools work without a live session:
286	  - `spt api --adapter <a> --manifest <file> capability` reports the manifest's
287	    hostable types from the manifest alone — assert it advertises the type your
288	    bringup spawns. (A clean `add` already proves the cross-field shape, since add
289	    is manifest-first; `capability` is the lighter, non-mutating check.)
290	  - `spt adapter digest-proof <a> --sample <file>` runs the real extractor
291	    through the registry and renders the result — proving the
292	    transcript → record → render path end-to-end on a fixed sample. It fills the
293	    same runtime substitution keys the daemon does, so passing proof means it
294	    works at runtime. (Use a recent `spt` — current binaries fill the full key
295	    map.)
296
297	And the meta-lesson: **observable behaviour of the public binary is itself public
298	surface.** When prose docs lag, a byte-capture against the live `api` / `adapter`
299	surface is a legitimate way to confirm a contract.
300
301	## Next
302
303	- **The full surface:** the [integration checklist](integration-checklist.md) —
304	  every contract surface grouped by necessity.
305	- **Reference:** the [manifest reference](manifest.md) and the
306	  [`spt api` surface](api.md).
307	- **Ship it:** the [install-on-demand bootstrap](install-on-demand.md).
308	- **Driven surfaces:** [Shells](../shells/overview.md) — the `kind = "shell"`
309	  flavour of this same contract.
310

# Adapter patterns & pitfalls

The [integration checklist](integration-checklist.md) tells you *which* surfaces
to wire. This page is the field guide: the patterns that decide whether an
adapter merely registers or runs like a native part of the harness — the design
rules, the lessons that save you a debugging session, and the cheapest ways to
prove each piece on the live binary.

Everything here is behaviour of the **shipped public surface** — the `spt`
binary, the [manifest](manifest.md), and the [`spt api`](api.md) commands,
verified against a live binary. It is harness-agnostic; where one harness's quirk
is the clearest illustration it is called out as such, and the pattern
generalizes to any harness with the same shape.

## The one rule: manifests are static, logic lives in binaries

If you internalize a single thing, make it this.

- **Manifest fields are static templates spt-core fills.** A field is a fixed
  template: spt-core substitutes `{key}` placeholders from a fixed
  [catalog](manifest.md#substitution-keys) (`{session_id}`, `{parent_pid}`,
  `{adapter_name}`, `{id}`, the digest/psyche keys), and `~` expands to home.
  That is the whole of a template's power.
- **Anything that depends on runtime state belongs in a binary the manifest
  points at** — the `[digest]` extractor, a `[session.*]` runner. Reading an env
  var, branching on runtime state, or computing a value is *logic*, so it lives
  in a binary. If your harness can move its own state directory at runtime, for
  example, treat the manifest `source` as a *fallback* root and have the binary
  it points at resolve the real location itself.
- **A `.toml`-only leaf carries no code of its own**, so verify it by registering
  and resolving it on the live binary (below), and put anything you want covered
  by real tests into a binary (an extractor or runner).

Hold this rule and most of the surface falls into place: the manifest is the
*declaration*, your binaries are the *behaviour*.

## The adapter lives in the registry

An adapter — its manifest, profiles, `[strings]`, the `[digest]` extractor, any
runner binaries — is registered with **`spt adapter add <dir>`** into the
node-local adapter registry. The version recorded there (`spt adapter list`) is
the **version-of-truth** for what the adapter does. That is the entire, universal
delivery mechanism: every spt adapter ships this way, and registration is where
spt-core validates it (see [the second gate](#validate-against-the-live-binary)).

If your harness *also* has a plugin or marketplace channel (so casual users can
one-click install it), that is a separate distribution choice on top. When you go
that route, let the **registry** carry the binary, manifest, and runtime state,
and version the plugin independently of the manifest/binary.

## Profiles are sparse leaf-replace overlays

A profile is selected as the composite `<adapter>:<profile>` and
**leaf-replaces only the leaves you declare** — everything else inherits from
base. Override exactly what differs:

- `[profiles.<name>.session.self].command` — retarget the bringup command (for
  example, wrap the launch in another binary).
- `[profiles.<name>.digest].<key>` — widen one digest knob.
- `[profiles.<name>.session.psyche_init]` — add the [live-agent
  seam](#the-live-agent-companion-seam); its presence on the merged view is what
  flips an endpoint to a live agent.

**Make an overlay observable.** Also leaf-replace one `[strings]` key (say a
label) in the profile. Then `spt adapter get-string <adapter>:<profile> <key>`
differs from the base value — and that diff is your proof the overlay resolved.
It is the cheapest profile acceptance assertion there is.

A profile that wraps the launch in another binary works when that binary is a
drop-in for the base harness binary on the same argv and passes inherited env
through unchanged. Routing a session through a launcher wrapper (a model or
billing multiplexer, say) is exactly this: replace the `session.self` command and
let the injected endpoint-id env ride through untouched.

## Wiring hooks: you own the harness side

spt-core supplies the harness-**independent** `spt api` primitives and their I/O
format. *You* author all harness-specific wiring: spt-core supplies the
primitives, and your adapter hand-writes its hook config to shell out to
`spt api`. A mapping that works on the public surface, in terms any harness can
translate to its own events:

| When the harness… | …fire | Why |
| --- | --- | --- |
| starts a session | `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}` | Seed the endpoint — keep this fast and non-blocking. |
| submits a user turn | `api poll {session_id}` | Drain the inbox to stdout (plus any keyword hints). |
| goes idle / busy | `api state idle` / `api state busy` | Honest activity; spt-core treats your explicit `api state` calls as the source of truth. |
| ends the session | `api session-end {session_id}` (or `api shutdown <id>` for graceful signoff) | Teardown that preserves the spool + history. |
| spawns / ends a sub-agent | `api worker-start` / `api worker-stop` | Nested short-lived workers. |

Two structural rules sit under that table:

- **Run the blocking listen/poll loop from a skill the user invokes.** Seed on
  start so bringup stays fast, and let an explicit `/ready`-style skill own the
  blocking stream.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting
  envelope `<EVENT type="msg" from="<sender>">body</EVENT>` (the live listener
  stream uses the same shape). Multi-message drains split cleanly on `</EVENT>`.
  Decode a body by splitting on `<br>` → newline, then HTML-unescaping
  `< > "` and `&` **last**. Route that stdout into your harness's
  injection channel — that routing is adapter glue.

### Get these right in the hook layer

A few patterns here save you a debugging session — wire them deliberately.

- **Pre-empt an injection channel's size cap.** If your harness caps the size of
  an injected blob (truncating it, or spilling it to a file and evicting it from
  the context the agent actually reads), cap the combined hook output
  adapter-side: under the limit, pass the output through verbatim; over it, spill
  the **full** text to an agent-readable file and inject a short pointer. Always
  cut on an `</EVENT>` boundary, so every envelope stays whole and every message
  survives a large drain.
- **Inject a skill body *before* the perch gate, and gate only the message
  drain.** When the same prompt hook both injects a requested skill's
  instructions and drains messages, run the skill-body injection first — that
  keeps skills like "who am I" or "set me up" working for a new user, since they
  are valid while the perch is still being readied. Match the skill token as a
  leading token, so only an actual invocation fires (prose that merely mentions
  it stays inert).
- **Make the setup/installer skill self-contained in its stub.** It runs
  precisely when the binary may be absent (installing it is the job), so carry
  its operative steps in the harness-native stub itself — the floor that always
  works — and let any file-backed body mirror them for the binary-present repair
  path. The one skill that most needs delivery is the one delivery reaches last,
  so give it a stub that stands alone.
- **Read hook inputs from stdin.** A hook receives its data (the prompt, the
  session id) as a JSON object on **stdin** — parse that, which keeps a
  `/`-leading value (a `/<skill>` token, an absolute path) intact. Under
  Git-Bash/MSYS on Windows, an argument beginning with `/` is rewritten to a
  Windows path before your command sees it (a `/foo:send` token can arrive as
  `C:/Program Files/Git/send`), so stdin is the transport that preserves it. If a
  command must take such an argument, guard it (`MSYS_NO_PATHCONV=1`, or a
  file/stdin transport). It is the same class as the UTF-8-stdout trap below —
  choose the transport that carries the data faithfully.

## `[strings]`: keep the manifest thin, point at the live binary

A `[strings]` value is either an inline string or a **file pointer**
(`key = { file = "relative/path" }`), resolved lazily by `spt adapter get-string`
to the file's contents — so live edits reflect without re-registering. Keep
pointer files inside the `strings/` dir; the add enforces that containment. Use
file pointers to keep skill-instruction bodies out of the manifest.

When a skill body needs to describe the spt surface, point it at the binary's own
self-documentation, so the guidance stays current with the shipped binary — two
always-current tiers:

- `spt how-to <topic>` is the task-oriented agent-guidance surface, covering
  **selected** topics, each a canonical write-up of verbs, flags, and result
  codes. Treat it as the curated tier: for a verb it covers, read the topic; for
  any other verb, probe and fall through (an undocumented topic returns
  `NO_SUCH_TOPIC:<topic>`).
- For any verb, **`spt <verb> --help` is the always-present source-of-truth** —
  it tracks the shipped binary. A skill body that says "the verb list is
  `spt <noun> --help` — match the user's intent to a verb" stays correct across
  releases.

Either tier stays current with the binary, and a skill body that points at them
stays correct across releases. They are also the fastest way to learn the surface
while authoring — it self-documents.

## `[digest]`: the transcript→record extractor

The `[digest]` seam maps your harness's native transcript into spt-core's
digest-record contract. The contract beyond the schema:

- **Name where it reads** — either `source` or a `[history].locate_template`.
  `spt adapter add` requires this even though the JSON schema alone would accept a
  bare `extractor`; the cross-field rule surfaces at registration, so validate
  against the live binary.
- **Treat `--in {source}` as a root.** The extractor is invoked
  `--session {session_id} --in {source}` and locates `<session_id>`'s transcript
  *within* that root — your harness's internal subdir scheme is yours to resolve,
  and spt-core keeps the key catalog harness-agnostic. Handle both shapes: `--in`
  a directory (locate the session) and `--in` a direct file (the
  `digest-proof --sample` path).
- **Resolve a runtime-relocated state tree in the binary.** When a runtime value
  (an env var, an isolated profile) moves the real transcript tree, have the
  extractor prefer that value on its directory branch, with the manifest `source`
  as the fallback root. That resolution is logic, so it lives in the binary — the
  headline rule in miniature.
- **Emit raw records as UTF-8.** Output one NDJSON line per record
  (`{role ∈ input|agent|tool, text?, tool?, ts?}`) and leave presentation to
  spt-core's renderer (`window_turns`, arg truncation, sprint collapse). Pin
  stdout to **UTF-8** so non-ASCII (em-dashes, smart quotes) round-trips — spt-core
  reads the stream as UTF-8. (Native-UTF-8 languages get this for free, which is
  part of why this seam is a binary.)

Prove the whole path with `spt adapter digest-proof <adapter> --sample <file>`
(below).

## The bringup / launcher seam

`[session.self].command` is the spt-hosted bringup template — spt-core spawns it
into a broker PTY. For a harness with no native session-id flag, mint the id
internally and pass the endpoint id via an injected env var
(`[env.<VAR>]` with `direction = "inject"`, `value = "{id}"`); the start hook
reads that env and self-registers with `api bind <id>`.

That bind is **intrinsically authenticated**: for a broker-spawned session the
broker parentage is the proof, so `api bind <id> --set-session-id <discovered>`
alone establishes the association, and later mutating calls prove themselves with
the session id the bind recorded. (The flip side shows up in
[testing](#testing-against-a-real-harness-isolate-identity): the framework keys
association on *identity*, so identity is the thing you isolate.)

`adapter.shortcut_basename` brands the generated launcher shortcut
(`<basename>-<id>`) and is decoupled from the adapter name.

## The live-agent (companion) seam

An endpoint is a **live agent** exactly when its *resolved* manifest declares
`[session.psyche_init]` — declaring that section is the single go-live signal. A
base manifest is a ready agent; a profile overlay that adds the section makes a
live agent. spt-core checks this on the **merged** view, so a profile selected at
**seed time** (`spt api --adapter <adapter>:<profile> seed`) drives the spawn
decision all the way through — the bound profile governs the full runtime
lifecycle, beyond bringup argv.

- `psyche_init` fills exactly four keys: `{id, session_id, psyche_dir,
  psyche_prompt}`. spt-core **overrides** `{id}` to `<parent>-psyche` before
  substitution, so the companion gets its own derived perch id. (The
  resume/preload key `{psyche_context}` is a separate seam; a first spawn fills
  it on resume.)
- The companion is launched **detached and fire-and-forget**: `detach = true`,
  `cwd = "{psyche_dir}"`, stdio null, handle dropped, **unsupervised**. Liveness
  stays daemon-authoritative through the companion's perch. It owns the
  `<parent>-psyche` perch, communicates over its perch and commune file-drops,
  and exits at session end.
- **The companion runner is yours to build; its lifecycle is the daemon's.**
  `psyche_init.command` is adapter-authored and opaque to spt-core. Declare the
  seam and build the runner, and let the daemon own spawn and teardown (a
  graceful `endpoint shutdown` tears the companion down with the perch).
- **If your harness's headless mode runs one turn per invocation**, make the
  runner a small **resident wrapper** so it persists across pulses: seed the
  companion once from `{psyche_prompt}`, then drive one resume-turn per perch
  pulse, with the companion authoring commune drops. Build it like the `[digest]`
  extractor — a compiled, dependency-light binary the daemon can exec bare on any
  platform.

## Lifecycle continuity is file-drops

Commune and signoff are delivered as **file-drops** by design. The agent writes
`<endpoint_id>-commune.md` (delta context) or `<endpoint_id>-signoff.md` (final
save) into the manifest-declared `[session].commune_dir` / `signoff_dir`;
spt-core's daemon watcher ingests it and deletes it (the daemon is the single
writer). The **filenames are contract-fixed** and the directory is
adapter-declared, so wire the directory watch and read the contract filename.
This is the single biggest continuity win, so it is worth getting exactly right.

## Testing against a real harness: isolate identity

The surest way to prove your hook wiring fires is an acceptance test that
**spawns a real harness session as the system-under-test**. Doing so meets a
framework property you design around:

- A perch's identity is **resolved from the environment** (the same vars
  `spt whoami` reads), and perches are **name-keyed, last-establish-wins**. The
  most recent session to establish a perch under a given identity holds it,
  taking the active poll/listen stream with it.
- So a spawned test session that loads your adapter (whose start hook seeds and
  binds a perch) under the identity of the agent running the tests would take
  that agent's perch. **Identity isolation is the guard.**
- Give every spawned system-under-test a disposable identity distinct from any
  live agent — override **both** identity env vars before the spawn to a
  throwaway `<adapter>-ci-<n>`, so the nested session and the operator's perch
  coexist cleanly. Identity is the key, so isolating identity is the whole guard.
- Keep the orchestration deterministic and **assert on a hook side-effect** — a
  marker or digest file, or `spt` state — the deterministic signal. Keep the
  harness as the system-under-test and let its side effects be your assertions.

## Validate against the live binary

Treat registration as the **second gate**: beyond JSON-schema validity,
`spt adapter add` runs cross-field checks that go past what the schema expresses
(the `[digest]` source rule is one). Build for it:

- A **registration integration check**: `adapter add` → `adapter list` (assert
  the adapter and each shipped profile composite resolves) → `get-string` (the
  base value, each overlay diff, and each file-backed pointer resolve to a body)
  → a soft `adapter remove` (leaving the registry clean). Gate it behind an
  opt-in env flag and a minimum `spt` version, since it mutates the node-local
  registry.
- Two author-time tools work without a live session:
  - `spt api --adapter <a> --manifest <file> capability` reports the manifest's
    hostable types from the manifest alone — assert it advertises the type your
    bringup spawns. (A clean `add` already proves the cross-field shape, since add
    is manifest-first; `capability` is the lighter, non-mutating check.)
  - `spt adapter digest-proof <a> --sample <file>` runs the real extractor
    through the registry and renders the result — proving the
    transcript → record → render path end-to-end on a fixed sample. It fills the
    same runtime substitution keys the daemon does, so passing proof means it
    works at runtime. (Use a recent `spt` — current binaries fill the full key
    map.)

And the meta-lesson: **observable behaviour of the public binary is itself public
surface.** When prose docs lag, a byte-capture against the live `api` / `adapter`
surface is a legitimate way to confirm a contract.

## Next

- **The full surface:** the [integration checklist](integration-checklist.md) —
  every contract surface grouped by necessity.
- **Reference:** the [manifest reference](manifest.md) and the
  [`spt api` surface](api.md).
- **Ship it:** the [install-on-demand bootstrap](install-on-demand.md).
- **Driven surfaces:** [Shells](../shells/overview.md) — the `kind = "shell"`
  flavour of this same contract.


Now check api listen surface + `--adapter` flag existence + REQ-DOCS-6 traceable stages + F-007 memory.


Found 5 files
crates\spt\src\api\mod.rs
crates\spt\src\api\reporting.rs
crates\spt\src\api\startup.rs
crates\spt\src\api\live.rs
crates\spt\src\api\delivery.rs


1	---
2	name: f007-live-relay-acceptance
3	description: "F-007 ruling — live bringup is persistent `api listen` via Monitor (heir to $LIVE start), NOT --once; psyche-spawn gated on --manifest + the v0.7.4 install-dir binary-resolution fix; both items Bucket 2"
4	metadata:
5	  node_type: memory
6	  type: project
7	  originSessionId: 8bff47ac-ae57-48ac-a383-fca01d85f232
8	---
9
10	perri's F-007 (spt-claude-code), doyle ruling 2026-06-16. Both items **Bucket 2 (docs gap)**, NOT missing-feature. (I initially mis-recommended `--once`; operator corrected — corrected below.)
11
12	**LIVE BRINGUP MODEL (CONTEXT.md:148/167):** a live agent is brought up by firing **`spt api listen <id>` (persistent, NO --once) inside the Monitor tool** — the long-running relay loop that binds the live perch, spawns the Psyche, streams EVENTs to stdout. This is the **heir to legacy `$LIVE start <id>`** (CONTEXT.md:148 verbatim). `--once` ("drain backlog + one cycle, then exit", api/mod.rs:66) is the **degenerate path for harnesses LACKING a Monitor equivalent** — terminates per message, WRONG for Claude Code (which has Monitor). Legacy parity: `$LIVE start doyle` (via Monitor) = LIVE; `$OWL listen doyle` = READY. spt-core has the parity primitive (`api listen` via Monitor) → no missing feature.
13
14	**ITEM 1 `spt how-to live` = NO_SUCH_TOPIC** — doc gap, sibling of [[howto-subnet-w5-scope]]. Add HOW_TO_LIVE topic (live bringup via Monitor + ready-vs-live + relay-reconcile + the acceptance recipe) + registry + v1-lock bump. Fast-follow post-M11.
15
16	**ITEM 2 non-interactive acceptance** — Bucket 2 (docs), not Bucket 3. Recipe (the harness plays Monitor's role): `api seed --pid <WINPID> --session-id <sid>` → spawn `spt api listen <id> --manifest <live manifest>` as a CHILD capturing stdout → wait READY → `spt send <id> <probe>` → read relayed EVENT off the child + assert Psyche → kill child (relay is "freely killable"). Pin OWL_SESSION_ID.
17
18	**PSYCHE-SPAWN root cause (perri's empirical gap):** spawn_psyche fires at startup.rs:288-294 (prints `PSYCHE_SPAWNED:`/`PSYCHE_SPAWN_FAIL:` to stderr) BEFORE the once/loop split — so it does NOT depend on --once-vs-persistent. It is gated on `LiveHost::new_opt` being Some, which needs a **`--manifest`** declaring `[session.psyche_init]`. perri's recipe passed only `--adapter` (a name string; manifest loads ONLY from `--manifest`, api/mod.rs:289) → manifest=None → no spawn attempted → no marker. FIX: add `--manifest`. THEN on 0.7.3 a bare psyche-binary name misses PATH → `PSYCHE_SPAWN_FAIL` → that is EXACTLY the **v0.7.4 Feature B / REQ-INSTALL-11** fix (install-dir binary resolution; on the api seam install_dir = --manifest parent dir) — already built. So perri's psyche gap = --manifest usage + the v0.7.4 fix, independently validating Feature B.
19
20	**GOTCHAS (perri-found, for the acceptance docs):** anchor pid must be the WINDOWS pid (git-bash `$$` = MSYS pid → STALE_SEED; derive WINPID via ps for --pid + --parent-pid); `send` to a never-bound perch = NO_PERCH (no queue) → `bind <id>` first, then send → QUEUED.
21
22	**Orchestration owed:** fold the how-to-live topic + this live-bringup/acceptance/psyche recipe into the post-M11 docs follow-on. Relay leg already shipped by perri. Detail in docs/SPT-CORE-FINDINGS.md F-007. Reinforces [[perri-question-triage-protocol]]; lesson: ground in CONTEXT.md before ruling (I erred recommending --once from the Rust-test path, not the Monitor model).
23

---
name: f007-live-relay-acceptance
description: "F-007 ruling — live bringup is persistent `api listen` via Monitor (heir to $LIVE start), NOT --once; psyche-spawn gated on --manifest + the v0.7.4 install-dir binary-resolution fix; both items Bucket 2"
metadata:
  node_type: memory
  type: project
  originSessionId: 8bff47ac-ae57-48ac-a383-fca01d85f232
---

perri's F-007 (spt-claude-code), doyle ruling 2026-06-16. Both items **Bucket 2 (docs gap)**, NOT missing-feature. (I initially mis-recommended `--once`; operator corrected — corrected below.)

**LIVE BRINGUP MODEL (CONTEXT.md:148/167):** a live agent is brought up by firing **`spt api listen <id>` (persistent, NO --once) inside the Monitor tool** — the long-running relay loop that binds the live perch, spawns the Psyche, streams EVENTs to stdout. This is the **heir to legacy `$LIVE start <id>`** (CONTEXT.md:148 verbatim). `--once` ("drain backlog + one cycle, then exit", api/mod.rs:66) is the **degenerate path for harnesses LACKING a Monitor equivalent** — terminates per message, WRONG for Claude Code (which has Monitor). Legacy parity: `$LIVE start doyle` (via Monitor) = LIVE; `$OWL listen doyle` = READY. spt-core has the parity primitive (`api listen` via Monitor) → no missing feature.

**ITEM 1 `spt how-to live` = NO_SUCH_TOPIC** — doc gap, sibling of [[howto-subnet-w5-scope]]. Add HOW_TO_LIVE topic (live bringup via Monitor + ready-vs-live + relay-reconcile + the acceptance recipe) + registry + v1-lock bump. Fast-follow post-M11.

**ITEM 2 non-interactive acceptance** — Bucket 2 (docs), not Bucket 3. Recipe (the harness plays Monitor's role): `api seed --pid <WINPID> --session-id <sid>` → spawn `spt api listen <id> --manifest <live manifest>` as a CHILD capturing stdout → wait READY → `spt send <id> <probe>` → read relayed EVENT off the child + assert Psyche → kill child (relay is "freely killable"). Pin OWL_SESSION_ID.

**PSYCHE-SPAWN root cause (perri's empirical gap):** spawn_psyche fires at startup.rs:288-294 (prints `PSYCHE_SPAWNED:`/`PSYCHE_SPAWN_FAIL:` to stderr) BEFORE the once/loop split — so it does NOT depend on --once-vs-persistent. It is gated on `LiveHost::new_opt` being Some, which needs a **`--manifest`** declaring `[session.psyche_init]`. perri's recipe passed only `--adapter` (a name string; manifest loads ONLY from `--manifest`, api/mod.rs:289) → manifest=None → no spawn attempted → no marker. FIX: add `--manifest`. THEN on 0.7.3 a bare psyche-binary name misses PATH → `PSYCHE_SPAWN_FAIL` → that is EXACTLY the **v0.7.4 Feature B / REQ-INSTALL-11** fix (install-dir binary resolution; on the api seam install_dir = --manifest parent dir) — already built. So perri's psyche gap = --manifest usage + the v0.7.4 fix, independently validating Feature B.

**GOTCHAS (perri-found, for the acceptance docs):** anchor pid must be the WINDOWS pid (git-bash `$$` = MSYS pid → STALE_SEED; derive WINPID via ps for --pid + --parent-pid); `send` to a never-bound perch = NO_PERCH (no queue) → `bind <id>` first, then send → QUEUED.

**Orchestration owed:** fold the how-to-live topic + this live-bringup/acceptance/psyche recipe into the post-M11 docs follow-on. Relay leg already shipped by perri. Detail in docs/SPT-CORE-FINDINGS.md F-007. Reinforces [[perri-question-triage-protocol]]; lesson: ground in CONTEXT.md before ruling (I erred recommending --once from the Rust-test path, not the Monitor model).


362:id = "REQ-DOCS-6"
363-title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
364-[Omitted long context line]
365-
366-# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
367-[[requirements]]
368-id = "REQ-SEC-1"
369-title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
370-required_stages = ["impl", "unit"]


361	[[requirements]]
362	id = "REQ-DOCS-6"
363	title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
364	required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.
365

[[requirements]]
id = "REQ-DOCS-6"
title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
required_stages = ["impl", "unit", "int"]  # activated M7 D1 (2026-06-05); int added M7 D5: quickstart_e2e asserts the how-to texts name the commands the published guide's prompt blocks rely on. M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.


crates\spt\src\api\startup.rs:260:    eprintln!("BOUND:{id} token={token}");
crates\spt\src\api\startup.rs:289:    eprintln!("READY:{id}");
crates\spt\src\api\startup.rs:357:            eprintln!("BOUND:{id} token={token}");
crates\spt\src\api\mod.rs:352:                    eprintln!("BOUND_SHELL:{shell_id} owner={owner} status=online");
crates\spt\src\api\reporting.rs:55:        eprintln!("BOUNDARY_FAIL:{id}: {e}");
crates\spt\src\api\reporting.rs:74:    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");


Found 1 file
crates\spt\tests\quickstart_e2e.rs


255	        Err(e) => {
256	            report_bind_error(&e);
257	            return EXIT_REFUSED;
258	        }
259	    };
260	    eprintln!("BOUND:{id} token={token}");
261
262	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
263	    let relay = match spt_daemon::relay::Relay::bind(id, &perch_path) {
264	        Ok(r) => r,
265	        Err(e) => {
266	            eprintln!("LISTEN_FAIL:{id}: {e}");
267	            return EXIT_REFUSED;
268	        }
269	    };
270
271	    // Live-agent FIRST-HOST handoff to the daemon (M11-W0.2, REQ-DAEMON-1): the
272	    // Psyche/pulse lifecycle is no longer hosted in THIS process — it lives in the
273	    // brain (`spt_daemon::livehost`). For a live-capable endpoint (manifest
274	    // declares `[session.psyche_init]`) we mark the perch `status=online` at
275	    // establish; the brain's reconcile then hosts the Psyche within one tick (the
276	    // shellwake CLI-flips-status / brain-reconciles pattern). `status` is
277	    // daemon-authoritative (CONTEXT:194). A new `listen` against a not-yet-restarted
278	    // OLD brain leaves the perch online-but-unhosted; that self-heals at the brain's
279	    // next BOOT reconcile (livehost reconciles at boot, not only on tick) — the
280	    // agent + the message relay below are live throughout regardless.
281	    // [impl->REQ-DAEMON-1]
282	    let live_capable = manifest.is_some_and(|m| m.session.psyche_init.is_some());
283	    if live_capable {
284	        if let Err(e) = info::set_status(&perch_path, spt_store::liveness::STATUS_ONLINE) {
285	            eprintln!("LIVE_ONLINE_FAIL:{id}: {e}");
286	        }
287	    }
288
289	    eprintln!("READY:{id}");
290
291	    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
292	    // undismissed notifs surface into the spool before the backlog drain, so
293	    // they ride this very first drain — and render endpoint-native when the
294	    // manifest declares [session.notif]. Best-effort — never fails the listen.
295	    super::reporting::resurface_notifs_with(id, manifest);
296
297	    // Deliver one message to the live agent and record it as an spt context
298	    // injection for the two-origin digest (REQ-TERM-7) — `emit` is the
299	    // agent-facing chokepoint, so every delivered message becomes a glanceable
300	    // `owl_message` context entry. The recorded form is the whole composed
301	    // `<EVENT>` (what the agent sees), not the structural pair. Best-effort;
302	    // never disturbs delivery.
303	    let mut deliver = |from: &str, body: &str| {
304	        emit(from, body);
305	        let event = spt_msg::emit::render_event_whole(from, body);
306	        spt_live::record_context_injection(id, spt_live::KIND_OWL_MESSAGE, &event);
307	    };
308
309	    if once {
310	        // Single-shot (testability): drain the offline backlog (incl the just-
311	        // resurfaced notifs) then forward one live delivery, and exit.
312	        relay.drain_backlog(&mut deliver);
313	        relay.pump_once(&mut deliver);
314	        return 0;
315	    }
316	    // The pure relay loop (M11-W0.2): `Relay::run` drains the backlog first
317	    // (preserving the resurface→drain ordering) then streams live deliveries to
318	    // stdout until the process is killed. No lifecycle state lives in this process
319	    // anymore — the Psyche/pulse runs in the brain. The stop flag is never raised;
320	    // the harness kills the process to end the session.
321	    let stop = std::sync::atomic::AtomicBool::new(false);
322	    relay.run(&stop, &mut deliver);
323	    0
324	}
325
326	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
327	/// live perch on first contact, or flips an existing skeleton, keyed to the
328	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
329	/// under a different session id is refused (you can only bind your own).
330	// [impl->REQ-SEAM-POSTSPAWN]
331	pub fn cmd_bind(
332	    id: &str,
333	    set_session_id: Option<String>,
334	    proof_session: Option<String>,
335	    adapter: Option<&str>,
336	    subnet: Option<&str>,
337	    endpoint_type: &str,
338	    manifest: Option<&spt_runtime::Manifest>,
339	) -> i32 {
340	    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
341	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
342	        eprintln!("INVALID_ID:{id}: {e}");
343	        return EXIT_REFUSED;
344	    }
345	    let session_id = set_session_id
346	        .or(proof_session)
347	        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
348	        .filter(|s| !s.is_empty());
349	    let Some(session_id) = session_id else {
350	        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
351	        return EXIT_REFUSED;
352	    };
353	    // The session's own anchor: the process that fired this hook (the session).
354	    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
355	    match establish_perch(id, &session_id, parent_pid, adapter, subnet, endpoint_type) {
356	        Ok(token) => {
357	            eprintln!("BOUND:{id} token={token}");
358	            // Live-agent FIRST-HOST handoff for the spt-hosted (bind, no listen)
359	            // topology (M11-W0.3, REQ-DAEMON-1) — the mirror of W0.2's cmd_listen
360	            // mark. A live_agent endpoint whose manifest declares
361	            // `[session.psyche_init]` is marked status=online at establish; the
362	            // brain's livehost reconcile then hosts the Psyche (the SAME single host
363	            // path — bind never spawns/hosts/calls livehost). Closes the
364	            // bind-no-Psyche gap: the Psyche spawn was previously cmd_listen-only, so

        Err(e) => {
            report_bind_error(&e);
            return EXIT_REFUSED;
        }
    };
    eprintln!("BOUND:{id} token={token}");

    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let relay = match spt_daemon::relay::Relay::bind(id, &perch_path) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("LISTEN_FAIL:{id}: {e}");
            return EXIT_REFUSED;
        }
    };

    // Live-agent FIRST-HOST handoff to the daemon (M11-W0.2, REQ-DAEMON-1): the
    // Psyche/pulse lifecycle is no longer hosted in THIS process — it lives in the
    // brain (`spt_daemon::livehost`). For a live-capable endpoint (manifest
    // declares `[session.psyche_init]`) we mark the perch `status=online` at
    // establish; the brain's reconcile then hosts the Psyche within one tick (the
    // shellwake CLI-flips-status / brain-reconciles pattern). `status` is
    // daemon-authoritative (CONTEXT:194). A new `listen` against a not-yet-restarted
    // OLD brain leaves the perch online-but-unhosted; that self-heals at the brain's
    // next BOOT reconcile (livehost reconciles at boot, not only on tick) — the
    // agent + the message relay below are live throughout regardless.
    // [impl->REQ-DAEMON-1]
    let live_capable = manifest.is_some_and(|m| m.session.psyche_init.is_some());
    if live_capable {
        if let Err(e) = info::set_status(&perch_path, spt_store::liveness::STATUS_ONLINE) {
            eprintln!("LIVE_ONLINE_FAIL:{id}: {e}");
        }
    }

    eprintln!("READY:{id}");

    // New-session-start is a reported resurface boundary (ADR-0007/D8b):
    // undismissed notifs surface into the spool before the backlog drain, so
    // they ride this very first drain — and render endpoint-native when the
    // manifest declares [session.notif]. Best-effort — never fails the listen.
    super::reporting::resurface_notifs_with(id, manifest);

    // Deliver one message to the live agent and record it as an spt context
    // injection for the two-origin digest (REQ-TERM-7) — `emit` is the
    // agent-facing chokepoint, so every delivered message becomes a glanceable
    // `owl_message` context entry. The recorded form is the whole composed
    // `<EVENT>` (what the agent sees), not the structural pair. Best-effort;
    // never disturbs delivery.
    let mut deliver = |from: &str, body: &str| {
        emit(from, body);
        let event = spt_msg::emit::render_event_whole(from, body);
        spt_live::record_context_injection(id, spt_live::KIND_OWL_MESSAGE, &event);
    };

    if once {
        // Single-shot (testability): drain the offline backlog (incl the just-
        // resurfaced notifs) then forward one live delivery, and exit.
        relay.drain_backlog(&mut deliver);
        relay.pump_once(&mut deliver);
        return 0;
    }
    // The pure relay loop (M11-W0.2): `Relay::run` drains the backlog first
    // (preserving the resurface→drain ordering) then streams live deliveries to
    // stdout until the process is killed. No lifecycle state lives in this process
    // anymore — the Psyche/pulse runs in the brain. The stop flag is never raised;
    // the harness kills the process to end the session.
    let stop = std::sync::atomic::AtomicBool::new(false);
    relay.run(&stop, &mut deliver);
    0
}

/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
/// live perch on first contact, or flips an existing skeleton, keyed to the
/// session id the binary reports. Auth is intrinsic: an existing *live* perch
/// under a different session id is refused (you can only bind your own).
// [impl->REQ-SEAM-POSTSPAWN]
pub fn cmd_bind(
    id: &str,
    set_session_id: Option<String>,
    proof_session: Option<String>,
    adapter: Option<&str>,
    subnet: Option<&str>,
    endpoint_type: &str,
    manifest: Option<&spt_runtime::Manifest>,
) -> i32 {
    // [impl->REQ-HAZARD-ID-CHARSET] reject malformed ids at the creation seam.
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return EXIT_REFUSED;
    }
    let session_id = set_session_id
        .or(proof_session)
        .or_else(|| std::env::var("OWL_SESSION_ID").ok())
        .filter(|s| !s.is_empty());
    let Some(session_id) = session_id else {
        eprintln!("USAGE: bind needs --set-session-id (or $OWL_SESSION_ID)");
        return EXIT_REFUSED;
    };
    // The session's own anchor: the process that fired this hook (the session).
    let parent_pid = proc::parent_pid().unwrap_or_else(std::process::id);
    match establish_perch(id, &session_id, parent_pid, adapter, subnet, endpoint_type) {
        Ok(token) => {
            eprintln!("BOUND:{id} token={token}");
            // Live-agent FIRST-HOST handoff for the spt-hosted (bind, no listen)
            // topology (M11-W0.3, REQ-DAEMON-1) — the mirror of W0.2's cmd_listen
            // mark. A live_agent endpoint whose manifest declares
            // `[session.psyche_init]` is marked status=online at establish; the
            // brain's livehost reconcile then hosts the Psyche (the SAME single host
            // path — bind never spawns/hosts/calls livehost). Closes the
            // bind-no-Psyche gap: the Psyche spawn was previously cmd_listen-only, so


Note: `api listen` no longer spawns Psyche in-process (M11-W0.2: brain/livehost hosts it on reconcile). Only BOUND + READY on stderr here. Where's PSYCHE_SPAWNED marker now?


No matches found


No PSYCHE_SPAWNED marker exists in tree. The M11 daemon refactor removed it — Psyche now hosted by brain livehost on reconcile, observable via the `<id>-psyche` perch, not a listen-stderr marker. Let me verify livehost markers + how a test observes psyche spawn now.


1://! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
43:/// Psyche; a `ready_agent` (messaging-only) never does.
131:/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
148:    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
192:            continue; // manifest declares no [session.psyche_init]
198:/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
201:/// A Psyche-spawn failure tables nothing — the next sweep retries.
211:    match lifecycle.spawn_psyche(Some(&session_id), None) {
212:        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
214:            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
215:            return; // no Psyche → no driver; retried next reconcile
261:    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
268:    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
276:             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
320:    // hosted by the reconcile — the Psyche is spawned AND the config-paced pulse
501:    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn


Key finding: on this branch (→ v0.8.0), `api listen` emits only `BOUND` + `READY`; the **daemon/brain livehost** hosts the Psyche on reconcile, emitting `LIVEHOST_PSYCHE:{id} pid=…` (livehost.rs:212). The old in-process `PSYCHE_SPAWNED` marker is **gone**. todlando's canonical recipe ("assert PSYCHE_SPAWNED:{id}-psyche off the child") describes the *published v0.7.3* model — stale for this branch.

Let me confirm how the branch's own tests observe live psyche spawn, to ground the recipe.


Found 2 files
crates\spt-daemon\tests\daemon_e2e.rs
crates\spt-daemon\tests\daemon_lifecycle_real_brain.rs


Found 58 files
docs-site\src\cli\reference.md
docs-site\src\harness-contract\manifest.md
docs\adr\0020-event-envelope-sole-arriving-format-reply-to-removed.md
traceable-reqs.toml
docs-site\src\harness-contract\patterns.md
docs-site\src\harness-contract\integration-checklist.md
docs-site\src\harness-contract\api.md
DAEMON-LIFECYCLE-COMPLETION-PLAN.md
CONTEXT.md
CHANGELOG.md
crates\spt\src\api\mod.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\endpoint.rs
crates\spt-runtime\src\manifest.rs
crates\spt-proto\src\event.rs
crates\spt-daemon\tests\daemon_e2e.rs
crates\spt-daemon\tests\daemon_lifecycle_real_brain.rs
crates\spt-daemon\src\livehost.rs
crates\spt\tests\live_bind_firsthost_e2e.rs
crates\spt\tests\live_firsthost_e2e.rs
crates\spt\src\api\startup.rs
crates\spt\tests\contract_e2e.rs
crates\spt\src\api\live.rs
crates\spt-daemon\src\brainproc.rs
docs\MANIFEST.md
docs\KNOWN-HAZARDS.md
crates\spt\src\api\delivery.rs
crates\spt-msg\src\emit.rs
crates\spt-daemon\src\psyrelay.rs
crates\spt-daemon\src\relay.rs
PRD.md
crates\spt-daemon\src\harnesshost.rs
M12-W1-PLAN.md
M12-CANDIDATES.md
crates\spt-store\src\sessions.rs
crates\spt-live\src\resume.rs
crates\spt-live\src\lib.rs
ROADMAP.md
crates\spt-store\src\perch.rs
adapters\mock\src\main.rs
crates\spt-daemon\src\lifecycle.rs
docs\adr\0004-single-daemon-broker-brain-split-and-self-update.md
crates\spt-store\src\seed.rs
crates\spt-store\src\rename.rs
crates\spt-store\src\liveness.rs
crates\spt-store\src\home.rs
crates\spt-proto\src\id.rs
crates\spt-live\src\psyche.rs
crates\spt-live\src\pulse.rs
crates\spt-daemon\src\seedmap.rs
M4-D9-PLAN.md
M4-PLAN.md
M4-D7.5-PLAN.md
M3b-PLAN.md
M3-PLAN.md
M2a-PLAN.md
M2b-PLAN.md
docs\reviews\STAGE-A-codex-redteam.md


1	//! M11-W0.2 first-host int (REQ-DAEMON-1) — the REAL establish-marks-online path,
2	//! end-to-end through the actual `spt` binary (no synthetic-online seed like the
3	//! W0.1 units):
4	//!
5	//! a live-capable `spt api listen --once` BINDS the Self perch and marks it
6	//! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
7	//! this process) → the brain's `livehost::reconcile_once` then HOSTS the Psyche
8	//! lifecycle off that real online status → a graceful offline transition un-hosts
9	//! it. This proves the cutover wiring: the spt process marks online, the brain
10	//! reconcile reacts, exactly the shellwake CLI-flips / brain-reconciles pattern.
11	//
12	// [int->REQ-DAEMON-1]
13
14	use std::path::PathBuf;
15	use std::process::{Command, Output};
16	use std::sync::Arc;
17	use std::time::Duration;
18
19	mod common;
20	use common::CommandNoWindowExt;
21
22	use spt_daemon::brainproc::StartReason;
23	use spt_daemon::config::DaemonConfig;
24	use spt_daemon::livehost::{self, LiveSet};
25	use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
26	use spt_store::perch::{self, ParentHint};
27
28	// A no-op Psyche summarizer: spawns + exits 0 (the daemon online-stamp hosts it,
29	// not the pid).
30	#[cfg(windows)]
31	const NOOP: &str = "cmd /C rem";
32	#[cfg(unix)]
33	const NOOP: &str = "true";
34
35	/// In-process seed daemon so `api seed`/`api listen` succeed without a REAL
36	/// detached `spt daemon` (shell_e2e's pattern).
37	fn start_inproc_daemon() {
38	    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
39	    let name = spt_daemon::seed_socket_name();
40	    std::thread::spawn(move || {
41	        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
42	    });
43	    for _ in 0..400 {
44	        if spt_daemon::is_running() {
45	            return;
46	        }
47	        std::thread::sleep(Duration::from_millis(5));
48	    }
49	    panic!("in-process seed daemon did not come up");
50	}
51
52	/// `Command::output()` with a deadline, run off-thread (bounds a hang into a
53	/// clean failure).
54	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
55	    let (tx, rx) = std::sync::mpsc::channel();
56	    std::thread::spawn(move || {
57	        let _ = tx.send(cmd.output());
58	    });
59	    rx.recv_timeout(deadline)
60	        .expect("captured spt call must complete")
61	        .expect("run spt")
62	}
63
64	#[test]
65	fn live_listen_marks_online_and_brain_reconcile_hosts() {
66	    // One test in this file: a plain set_var is safe; every child inherits the home.
67	    let home = tempfile::tempdir().unwrap();
68	    std::env::set_var("SPT_HOME", home.path());
69	    start_inproc_daemon();
70
71	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
72
73	    // A live-capable adapter (declares [session.psyche_init]) + register it so the
74	    // brain reconcile can resolve the manifest from disk by the perch's adapter.
75	    let drops = home.path().join("drops");
76	    std::fs::create_dir_all(&drops).unwrap();
77	    let dir = drops.to_string_lossy().replace('\\', "/");
78	    let manifest_toml = format!(
79	        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
80	         min_spt_core_version=\"0\"\n\n\
81	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
82	         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
83	    );
84	    let manifest_path = home.path().join("mocklive.toml");
85	    std::fs::write(&manifest_path, &manifest_toml).unwrap();
86	    let src = perch::spt_home().join("srcs").join("mocklive");
87	    std::fs::create_dir_all(&src).unwrap();
88	    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
89	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
90
91	    let pid = std::process::id().to_string();
92	    let mp = manifest_path.to_string_lossy().to_string();
93	    let spt = |args: &[&str]| {
94	        let mut cmd = Command::new(&spt_bin);
95	        cmd.no_window().args(args).env("SPT_HOME", home.path());
96	        output_bounded(cmd, Duration::from_secs(60))
97	    };
98
99	    // ── seed (keyed by THIS process's pid — alive, passes the recycle guard) ──
100	    let out = spt(&[
101	        "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-1",
102	    ]);
103	    assert!(out.status.success(), "seed: {}", String::from_utf8_lossy(&out.stderr));
104
105	    // ── the REAL live listen --once: binds the Self perch + marks it online
106	    // (the W0.2 establish-marks-online), drains, exits. The Psyche does NOT spawn
107	    // in this process anymore.
108	    let out = spt(&[
109	        "api", "--adapter", "mocklive", "--manifest", &mp, "listen", "agent7",
110	        "--parent-pid", &pid, "--once",
111	    ]);
112	    assert!(out.status.success(), "listen: {}", String::from_utf8_lossy(&out.stderr));
113
114	    // The real establish-marks-online: the perch carries status=online, written by
115	    // cmd_listen (NOT hand-seeded).
116	    let self_perch = perch::resolve_perch_path("agent7", ParentHint::Infer);
117	    let status = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
118	    assert_eq!(
119	        status.as_deref(),
120	        Some(STATUS_ONLINE),
121	        "a live `api listen` marks the perch online (the first-host handoff)"
122	    );
123
124	    // ── the brain reconcile hosts off that REAL online status ──
125	    let set = Arc::new(LiveSet::new());
126	    // FAST pulse: the driver hammers set_last_active every tick. The W0.4
127	    // mutate_info sentinel lock makes the offline flip below survive that
128	    // contention, so no long-pulse+settle workaround is needed (the lock, not
129	    // timing, makes the un-host deterministic).
130	    let cfg = DaemonConfig {
131	        pulse_period: Duration::from_millis(15),
132	        ..DaemonConfig::default()
133	    };
134	    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
135	    livehost::reconcile_once(
136	        &perch::owlery_dir(),
137	        &registered,
138	        &perch::adapters_dir(),
139	        &set,
140	        &cfg,
141	        StartReason::Cold,
142	    );
143	    assert_eq!(
144	        set.len(),
145	        1,
146	        "the brain reconcile hosts the live-listen-onlined endpoint"
147	    );
148
149	    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
150	    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's
151	    // concurrent per-tick set_last_active (un-host fires under contention).
152	    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
153	    livehost::reconcile_once(
154	        &perch::owlery_dir(),
155	        &registered,
156	        &perch::adapters_dir(),
157	        &set,
158	        &cfg,
159	        StartReason::Cold,
160	    );
161	    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");
162
163	    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
164	    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
165	        if let Ok(p) = p.trim().parse::<u32>() {
166	            #[cfg(windows)]
167	            let _ = Command::new("taskkill")
168	                .no_window()
169	                .args(["/PID", &p.to_string(), "/F"])
170	                .output();
171	            #[cfg(unix)]
172	            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
173	        }
174	    }
175	}
176

//! M11-W0.2 first-host int (REQ-DAEMON-1) — the REAL establish-marks-online path,
//! end-to-end through the actual `spt` binary (no synthetic-online seed like the
//! W0.1 units):
//!
//! a live-capable `spt api listen --once` BINDS the Self perch and marks it
//! `status=online` (the W0.2 first-host handoff — the Psyche no longer spawns in
//! this process) → the brain's `livehost::reconcile_once` then HOSTS the Psyche
//! lifecycle off that real online status → a graceful offline transition un-hosts
//! it. This proves the cutover wiring: the spt process marks online, the brain
//! reconcile reacts, exactly the shellwake CLI-flips / brain-reconciles pattern.
//
// [int->REQ-DAEMON-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::Arc;
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

use spt_daemon::brainproc::StartReason;
use spt_daemon::config::DaemonConfig;
use spt_daemon::livehost::{self, LiveSet};
use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
use spt_store::perch::{self, ParentHint};

// A no-op Psyche summarizer: spawns + exits 0 (the daemon online-stamp hosts it,
// not the pid).
#[cfg(windows)]
const NOOP: &str = "cmd /C rem";
#[cfg(unix)]
const NOOP: &str = "true";

/// In-process seed daemon so `api seed`/`api listen` succeed without a REAL
/// detached `spt daemon` (shell_e2e's pattern).
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline, run off-thread (bounds a hang into a
/// clean failure).
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect("captured spt call must complete")
        .expect("run spt")
}

#[test]
fn live_listen_marks_online_and_brain_reconcile_hosts() {
    // One test in this file: a plain set_var is safe; every child inherits the home.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());
    start_inproc_daemon();

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));

    // A live-capable adapter (declares [session.psyche_init]) + register it so the
    // brain reconcile can resolve the manifest from disk by the perch's adapter.
    let drops = home.path().join("drops");
    std::fs::create_dir_all(&drops).unwrap();
    let dir = drops.to_string_lossy().replace('\\', "/");
    let manifest_toml = format!(
        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
         min_spt_core_version=\"0\"\n\n\
         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
         [session.psyche_init]\ncommand='{NOOP}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
    );
    let manifest_path = home.path().join("mocklive.toml");
    std::fs::write(&manifest_path, &manifest_toml).unwrap();
    let src = perch::spt_home().join("srcs").join("mocklive");
    std::fs::create_dir_all(&src).unwrap();
    std::fs::write(src.join("manifest.toml"), &manifest_toml).unwrap();
    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();

    let pid = std::process::id().to_string();
    let mp = manifest_path.to_string_lossy().to_string();
    let spt = |args: &[&str]| {
        let mut cmd = Command::new(&spt_bin);
        cmd.no_window().args(args).env("SPT_HOME", home.path());
        output_bounded(cmd, Duration::from_secs(60))
    };

    // ── seed (keyed by THIS process's pid — alive, passes the recycle guard) ──
    let out = spt(&[
        "api", "--adapter", "mocklive", "seed", "--pid", &pid, "--session-id", "sid-1",
    ]);
    assert!(out.status.success(), "seed: {}", String::from_utf8_lossy(&out.stderr));

    // ── the REAL live listen --once: binds the Self perch + marks it online
    // (the W0.2 establish-marks-online), drains, exits. The Psyche does NOT spawn
    // in this process anymore.
    let out = spt(&[
        "api", "--adapter", "mocklive", "--manifest", &mp, "listen", "agent7",
        "--parent-pid", &pid, "--once",
    ]);
    assert!(out.status.success(), "listen: {}", String::from_utf8_lossy(&out.stderr));

    // The real establish-marks-online: the perch carries status=online, written by
    // cmd_listen (NOT hand-seeded).
    let self_perch = perch::resolve_perch_path("agent7", ParentHint::Infer);
    let status = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
    assert_eq!(
        status.as_deref(),
        Some(STATUS_ONLINE),
        "a live `api listen` marks the perch online (the first-host handoff)"
    );

    // ── the brain reconcile hosts off that REAL online status ──
    let set = Arc::new(LiveSet::new());
    // FAST pulse: the driver hammers set_last_active every tick. The W0.4
    // mutate_info sentinel lock makes the offline flip below survive that
    // contention, so no long-pulse+settle workaround is needed (the lock, not
    // timing, makes the un-host deterministic).
    let cfg = DaemonConfig {
        pulse_period: Duration::from_millis(15),
        ..DaemonConfig::default()
    };
    let registered = spt_runtime::registry::registered(&perch::adapters_dir());
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert_eq!(
        set.len(),
        1,
        "the brain reconcile hosts the live-listen-onlined endpoint"
    );

    // ── a graceful offline transition un-hosts it (the exit guard, end-to-end) ──
    // No settle: the W0.4 sentinel lock makes the offline flip survive the driver's
    // concurrent per-tick set_last_active (un-host fires under contention).
    spt_store::info::set_status(&self_perch, STATUS_OFFLINE).unwrap();
    livehost::reconcile_once(
        &perch::owlery_dir(),
        &registered,
        &perch::adapters_dir(),
        &set,
        &cfg,
        StartReason::Cold,
    );
    assert!(set.is_empty(), "an offline transition un-hosts the lifecycle");

    // Reap any REAL daemon that auto-started against this throwaway home (hygiene).
    if let Ok(p) = std::fs::read_to_string(home.path().join("daemon.pid")) {
        if let Ok(p) = p.trim().parse::<u32>() {
            #[cfg(windows)]
            let _ = Command::new("taskkill")
                .no_window()
                .args(["/PID", &p.to_string(), "/F"])
                .output();
            #[cfg(unix)]
            let _ = Command::new("kill").args(["-9", &p.to_string()]).output();
        }
    }
}


Fully confirmed branch model via `live_firsthost_e2e.rs`. Now check the int test (quickstart_e2e) and whether `--adapter`-resolves-manifest exists on this branch.


1	//! M6-D4 quickstart rung (rewritten M7 D5) — the published human quickstart
2	//! (docs-site/src/quickstart/messaging.md) executed step for step against the
3	//! real binary: the `how-to` topics the agent prompt blocks point at name the
4	//! commands the guide relies on → ready brings sergey online → live send
5	//! delivers (`SENT`) and the `<EVENT type="msg" from=…>` envelope lands on
6	//! sergey's stdout → sergey goes away → send spools (`QUEUED`) → `ready --once` drains
7	//! the backlog. If the documented flow stops working, this fails — the doc
8	//! cannot silently rot (REQ-DOCS-2's "runnable, deterministic" promise,
9	//! CI-run on both platforms). The pairing `<details>` section is two-machine
10	//! by nature — its evidence is the twohost ladder's join rung, not this test.
11	//!
12	//! Hermetic: isolated SPT_HOME per run; not env-gated (cheap, local-only).
13	//
14	// [int->REQ-DOCS-2]
15
16	use std::io::{BufRead, BufReader, Read};
17	use std::process::{Command, Stdio};
18	use std::time::Duration;
19
20	mod common;
21	use common::CommandNoWindowExt;
22
23	fn spt() -> Command {
24	    let mut c = Command::new(env!("CARGO_BIN_EXE_spt"));
25	    c.no_window();
26	    c
27	}
28
29	#[test]
30	fn published_messaging_quickstart_runs_as_written() {
31	    let home = tempfile::tempdir().expect("tempdir");
32	    let home = home.path();
33
34	    // ---- steps 3/4 preamble: the agent prompt blocks say "run `spt how-to
35	    // <topic>` and follow it" — assert the in-binary guidance actually names
36	    // the commands the guide's captured outputs rely on (REQ-DOCS-6: the
37	    // binary is the single source; if these texts rot, the quickstart's
38	    // prompts mislead every agent that follows them).
39	    // [int->REQ-DOCS-6]
40	    let topics = run_ok(home, &["how-to"]);
41	    assert!(
42	        topics.contains("ready"),
43	        "topic list names ready: {topics:?}"
44	    );
45	    assert!(topics.contains("send"), "topic list names send: {topics:?}");
46	    let ready_text = run_ok(home, &["how-to", "ready"]);
47	    assert!(
48	        ready_text.contains("spt ready <id>"),
49	        "ready how-to names the command"
50	    );
51	    assert!(
52	        ready_text.contains("--once"),
53	        "ready how-to teaches the one-shot fallback"
54	    );
55	    assert!(
56	        ready_text.contains("<EVENT type="),
57	        "ready how-to explains the EVENT envelope delivery shape"
58	    );
59	    let send_text = run_ok(home, &["how-to", "send"]);
60	    assert!(
61	        send_text.contains("spt send <target>"),
62	        "send how-to names the command"
63	    );
64	    assert!(
65	        send_text.contains("QUEUED"),
66	        "send how-to explains SENT vs QUEUED"
67	    );
68
69	    // ---- step 3: the receiver agent comes online (`spt ready sergey`) ----
70	    let mut sergey = spt()
71	        .args(["ready", "sergey"])
72	        .env("SPT_HOME", home)
73	        .stdout(Stdio::piped())
74	        .stderr(Stdio::piped())
75	        .spawn()
76	        .expect("spawn ready sergey");
77	    // READY:sergey is the online signal (stderr).
78	    let mut sergey_err = BufReader::new(sergey.stderr.take().unwrap());
79	    let mut line = String::new();
80	    sergey_err.read_line(&mut line).expect("read READY line");
81	    assert!(
82	        line.contains("READY:sergey"),
83	        "expected READY:sergey, got {line:?}"
84	    );
85
86	    // ---- step 4: live send from lea ----
87	    let out = send(
88	        home,
89	        &["send", "sergey", "--from", "lea"],
90	        "hello sergey - lea here",
91	    );
92	    assert!(
93	        out.contains("SENT:sergey"),
94	        "live delivery must report SENT:sergey, got {out:?}"
95	    );
96	    // The documented receive shape: one EVENT envelope line on sergey's
97	    // stdout — from attribute carries the sender, body rides inside.
98	    // [int->REQ-MSG-4]
99	    let mut sergey_out = BufReader::new(sergey.stdout.take().unwrap());
100	    let mut l1 = String::new();
101	    sergey_out.read_line(&mut l1).expect("read envelope line");
102	    assert!(
103	        l1.contains(r#"<EVENT type="msg" from="lea">hello sergey - lea here</EVENT>"#),
104	        "envelope line, got {l1:?}"
105	    );
106
107	    // ---- step 5: sergey goes away; the send spools ----
108	    sergey.kill().expect("stop sergey");
109	    let _ = sergey.wait();
110	    let out = send(
111	        home,
112	        &["send", "sergey", "--from", "lea"],
113	        "ping while you were away",
114	    );
115	    assert!(
116	        out.contains("QUEUED:sergey"),
117	        "offline delivery must report QUEUED:sergey, got {out:?}"
118	    );
119
120	    // ---- sergey comes back: the backlog drains (`ready --once`) ----
121	    let drained = spt()
122	        .args(["ready", "sergey", "--once"])
123	        .env("SPT_HOME", home)
124	        .output()
125	        .expect("ready --once");
126	    let stdout = String::from_utf8_lossy(&drained.stdout);
127	    // The backlog path renders the same envelope shape as the live path.
128	    // [int->REQ-MSG-4]
129	    assert!(
130	        stdout.contains(r#"<EVENT type="msg" from="lea">ping while you were away</EVENT>"#),
131	        "backlog must drain enveloped on return, got {stdout:?}"
132	    );
133	}
134
135	/// Run a one-shot spt command expecting exit 0; return its stdout.
136	fn run_ok(home: &std::path::Path, args: &[&str]) -> String {
137	    let out = spt()
138	        .args(args)
139	        .env("SPT_HOME", home)
140	        .output()
141	        .expect("spt runs");
142	    assert!(
143	        out.status.success(),
144	        "`spt {}` failed: {:?}",
145	        args.join(" "),
146	        out
147	    );
148	    String::from_utf8_lossy(&out.stdout).into_owned()
149	}
150
151	/// Run a one-shot spt command with `body` on stdin, returning stdout+stderr.
152	fn send(home: &std::path::Path, args: &[&str], body: &str) -> String {
153	    let mut child = spt()
154	        .args(args)
155	        .env("SPT_HOME", home)
156	        .stdin(Stdio::piped())
157	        .stdout(Stdio::piped())
158	        .stderr(Stdio::piped())
159	        .spawn()
160	        .expect("spawn spt");
161	    {
162	        use std::io::Write;
163	        child
164	            .stdin
165	            .take()
166	            .unwrap()
167	            .write_all(body.as_bytes())
168	            .expect("write body");
169	    }
170	    // Bounded read (KNOWN-HAZARDS 5.6 posture): a hang here means a detached
171	    // child inherited the pipe.
172	    let (tx, rx) = std::sync::mpsc::channel();
173	    std::thread::spawn(move || {
174	        let out = child.wait_with_output();
175	        let _ = tx.send(out);
176	    });
177	    let out = rx
178	        .recv_timeout(Duration::from_secs(30))
179	        .expect("spt call must complete")
180	        .expect("spt runs");
181	    let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
182	    let mut e = String::new();
183	    let _ = std::io::Cursor::new(out.stderr).read_to_string(&mut e);
184	    s.push_str(&e);
185	    s
186	}
187

//! M6-D4 quickstart rung (rewritten M7 D5) — the published human quickstart
//! (docs-site/src/quickstart/messaging.md) executed step for step against the
//! real binary: the `how-to` topics the agent prompt blocks point at name the
//! commands the guide relies on → ready brings sergey online → live send
//! delivers (`SENT`) and the `<EVENT type="msg" from=…>` envelope lands on
//! sergey's stdout → sergey goes away → send spools (`QUEUED`) → `ready --once` drains
//! the backlog. If the documented flow stops working, this fails — the doc
//! cannot silently rot (REQ-DOCS-2's "runnable, deterministic" promise,
//! CI-run on both platforms). The pairing `<details>` section is two-machine
//! by nature — its evidence is the twohost ladder's join rung, not this test.
//!
//! Hermetic: isolated SPT_HOME per run; not env-gated (cheap, local-only).
//
// [int->REQ-DOCS-2]

use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::Duration;

mod common;
use common::CommandNoWindowExt;

fn spt() -> Command {
    let mut c = Command::new(env!("CARGO_BIN_EXE_spt"));
    c.no_window();
    c
}

#[test]
fn published_messaging_quickstart_runs_as_written() {
    let home = tempfile::tempdir().expect("tempdir");
    let home = home.path();

    // ---- steps 3/4 preamble: the agent prompt blocks say "run `spt how-to
    // <topic>` and follow it" — assert the in-binary guidance actually names
    // the commands the guide's captured outputs rely on (REQ-DOCS-6: the
    // binary is the single source; if these texts rot, the quickstart's
    // prompts mislead every agent that follows them).
    // [int->REQ-DOCS-6]
    let topics = run_ok(home, &["how-to"]);
    assert!(
        topics.contains("ready"),
        "topic list names ready: {topics:?}"
    );
    assert!(topics.contains("send"), "topic list names send: {topics:?}");
    let ready_text = run_ok(home, &["how-to", "ready"]);
    assert!(
        ready_text.contains("spt ready <id>"),
        "ready how-to names the command"
    );
    assert!(
        ready_text.contains("--once"),
        "ready how-to teaches the one-shot fallback"
    );
    assert!(
        ready_text.contains("<EVENT type="),
        "ready how-to explains the EVENT envelope delivery shape"
    );
    let send_text = run_ok(home, &["how-to", "send"]);
    assert!(
        send_text.contains("spt send <target>"),
        "send how-to names the command"
    );
    assert!(
        send_text.contains("QUEUED"),
        "send how-to explains SENT vs QUEUED"
    );

    // ---- step 3: the receiver agent comes online (`spt ready sergey`) ----
    let mut sergey = spt()
        .args(["ready", "sergey"])
        .env("SPT_HOME", home)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn ready sergey");
    // READY:sergey is the online signal (stderr).
    let mut sergey_err = BufReader::new(sergey.stderr.take().unwrap());
    let mut line = String::new();
    sergey_err.read_line(&mut line).expect("read READY line");
    assert!(
        line.contains("READY:sergey"),
        "expected READY:sergey, got {line:?}"
    );

    // ---- step 4: live send from lea ----
    let out = send(
        home,
        &["send", "sergey", "--from", "lea"],
        "hello sergey - lea here",
    );
    assert!(
        out.contains("SENT:sergey"),
        "live delivery must report SENT:sergey, got {out:?}"
    );
    // The documented receive shape: one EVENT envelope line on sergey's
    // stdout — from attribute carries the sender, body rides inside.
    // [int->REQ-MSG-4]
    let mut sergey_out = BufReader::new(sergey.stdout.take().unwrap());
    let mut l1 = String::new();
    sergey_out.read_line(&mut l1).expect("read envelope line");
    assert!(
        l1.contains(r#"<EVENT type="msg" from="lea">hello sergey - lea here</EVENT>"#),
        "envelope line, got {l1:?}"
    );

    // ---- step 5: sergey goes away; the send spools ----
    sergey.kill().expect("stop sergey");
    let _ = sergey.wait();
    let out = send(
        home,
        &["send", "sergey", "--from", "lea"],
        "ping while you were away",
    );
    assert!(
        out.contains("QUEUED:sergey"),
        "offline delivery must report QUEUED:sergey, got {out:?}"
    );

    // ---- sergey comes back: the backlog drains (`ready --once`) ----
    let drained = spt()
        .args(["ready", "sergey", "--once"])
        .env("SPT_HOME", home)
        .output()
        .expect("ready --once");
    let stdout = String::from_utf8_lossy(&drained.stdout);
    // The backlog path renders the same envelope shape as the live path.
    // [int->REQ-MSG-4]
    assert!(
        stdout.contains(r#"<EVENT type="msg" from="lea">ping while you were away</EVENT>"#),
        "backlog must drain enveloped on return, got {stdout:?}"
    );
}

/// Run a one-shot spt command expecting exit 0; return its stdout.
fn run_ok(home: &std::path::Path, args: &[&str]) -> String {
    let out = spt()
        .args(args)
        .env("SPT_HOME", home)
        .output()
        .expect("spt runs");
    assert!(
        out.status.success(),
        "`spt {}` failed: {:?}",
        args.join(" "),
        out
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

/// Run a one-shot spt command with `body` on stdin, returning stdout+stderr.
fn send(home: &std::path::Path, args: &[&str], body: &str) -> String {
    let mut child = spt()
        .args(args)
        .env("SPT_HOME", home)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spt");
    {
        use std::io::Write;
        child
            .stdin
            .take()
            .unwrap()
            .write_all(body.as_bytes())
            .expect("write body");
    }
    // Bounded read (KNOWN-HAZARDS 5.6 posture): a hang here means a detached
    // child inherited the pipe.
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let out = child.wait_with_output();
        let _ = tx.send(out);
    });
    let out = rx
        .recv_timeout(Duration::from_secs(30))
        .expect("spt call must complete")
        .expect("spt runs");
    let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
    let mut e = String::new();
    let _ = std::io::Cursor::new(out.stderr).read_to_string(&mut e);
    s.push_str(&e);
    s
}


Now verify whether `--adapter` alone resolves a manifest on this branch (canonical content claims v0.8.0+).


3://! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
13://! (auth refused, no endpoint, bad manifest) · `2` usage (clap).
34:/// The `api` command group. `--adapter` is required on every invocation; an
35:/// optional `--manifest` is parsed for commands that need the adapter contract
42:    /// Path to the adapter's runtime manifest (when the command needs it).
44:    manifest: Option<PathBuf>,
242:    /// per `--session` (a `/clear` = a new session = re-armed). Needs `--manifest`.
257:    /// manifest `can_shutdown` pre-consent grant, fail-closed. The firing
289:    /// Parsed manifest, when `--manifest` was supplied (and valid).
290:    pub manifest: Option<Manifest>,
310:    // Resolve the manifest up front so a bad path/content fails fast and
312:    let manifest = match &args.manifest {
313:        Some(path) => match load_manifest(path, &args.adapter) {
321:        manifest,
339:            ctx.manifest.as_ref(),
353:                    // M11-W3 (REQ-SHELL-4): open the opaque tunnel if the manifest
381:            // --adapter (no seed on this path) — the adapter-at-creation rule.
390:                ctx.manifest.as_ref(),
414:                delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
442:            let manifest = ctx.manifest.as_ref();
443:            gated(&id, &auth, |id| live::cmd_shutdown(id, manifest))
461:/// Load + validate the adapter's runtime manifest and resolve the `--adapter`
462:/// option through the merged view. `--adapter` is a `<adapter>[:profile]`
469:fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
474:    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
479:    if manifest.adapter.name != parent {
481:            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
482:            manifest.adapter.name
487:    spt_runtime::registry::resolve_option_onto(&manifest, &adapters_dir, adapter).map_err(|e| {
488:        eprintln!("PROFILE_UNRESOLVED: --adapter {adapter}: {e}");
529:        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
532:    // [unit->REQ-MANIFEST-2] --adapter is a `<adapter>[:profile]` composite: the
534:    // overlays the runtime manifest so the advertised view (here hostable_types)
537:    fn load_manifest_resolves_composite_adapter_option() {
541:        let path = dir.join("manifest.toml");
551:        let bare = load_manifest(&path, "mock-h").expect("bare resolves");
555:        let prof = load_manifest(&path, "mock-h:full").expect("profile resolves");
559:        assert_eq!(load_manifest(&path, "other"), Err(EXIT_REFUSED));
561:        assert_eq!(load_manifest(&path, "mock-h:ghost"), Err(EXIT_REFUSED));
568:            "--adapter",

[Showing results with pagination = limit: 40]


Confirmed: on this branch the manifest resolves **only** from `--manifest` (mod.rs:312); `--adapter`-alone-resolves is the future "api ergonomics fix" → genuinely v0.8.0. todlando's "(v0.8.0+)" framing is forward-correct for the release this ships in.

Quick verify of the `live_agent` kind string + psyche perch online marker before drafting.


docs\BROKER-BRAIN-SPLIT-RESTORATION.md:25:While verifying the v0.3.2 cross-OS update fix end-to-end on the real fleet, the third node (`enlyzeam`, a Windows box hosting the `gerald` ready_agent) was updated:
adapters\mock\src\main.rs:52:    // a Gateway — binds with `--type gateway`. Absent ⇒ the default live_agent.
crates\spt-msg\tests\killer_quickstart.rs:92:        &InfoJson::new(&c, "now", std::process::id(), "sess", "ready_agent"),
crates\spt-msg\tests\killer_quickstart.rs:126:        &InfoJson::new(&target, "now", std::process::id(), "sess", "ready_agent"),
crates\spt\tests\tunnel_e2e.rs:155:    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
crates\spt\tests\shell_sleepwake_e2e.rs:155:    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
crates\spt\tests\shell_e2e.rs:137:    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
crates\spt-msg\src\ring.rs:128:    let rec = InfoJson::new(from, "once", std::process::id(), "ring", "ready_agent");
crates\spt-msg\src\ring.rs:232:        let rec = InfoJson::new(&target, "now", std::process::id(), "sess", "ready_agent");
crates\spt-daemon\tests\wanmsg.rs:110:    let rec = InfoJson::new(id, "now", std::process::id(), "sess", "ready_agent");
crates\spt-msg\src\ready.rs:11://! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
crates\spt-msg\src\ready.rs:78:            "ready_agent",
crates\spt-msg\src\ready.rs:233:        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
crates\spt\tests\shell_actgate_e2e.rs:125:    let rec = spt_store::info::InfoJson::new("doyle", "0", std::process::id(), "sid", "live_agent");
crates\spt-daemon\tests\two_origin_spanning.rs:78:    let mut rec = InfoJson::new("cc", "0", std::process::id(), "B", "live_agent");

[Showing results with pagination = limit: 15]


All grounded. Writing now. First, the `HOW_TO_LIVE` const + registry entry.


Edit

Leaving: `spt subnet leave <name>` drops this machine's membership + trust.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; `subnet` added M11-W5 (REQ-DOCS-6) — the highest-value
/// first-run flow, previously a NO_SUCH_TOPIC dead-end. Bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
    (
        "subnet",
        "pair machines: create vs join, the 6-digit code, reaching remote agents",
        HOW_TO_SUBNET,
    ),
];

Leaving: `spt subnet leave <name>` drops this machine's membership + trust.
";

/// `spt how-to live` — live-agent bringup guidance for agents.
const HOW_TO_LIVE: &str = "\
# how-to: live — run as a live agent (a Self + a Psyche companion)

A LIVE agent is a persistent being: a Self (this endpoint) plus a Psyche
companion process that carries its context across resets. A READY agent is the
lighter form — a perch with a poll/listen listener and no Psyche
(`how-to ready`). Go live for a companion that persists and communes; stay
ready for plain messaging.

Bringup — run the PERSISTENT relay loop (this is the live path):

    spt api listen <id> --manifest <manifest>

- Fire it through your harness's LONG-RUNNING-listener mechanism — a tool that
  keeps the process alive and re-fires per event WITHOUT terminating it. The
  loop binds the live perch, brings up your Psyche companion, and streams
  `<EVENT>` lines to stdout for the life of the session.
- Stderr signals: `BOUND:<id>` (perch established) then `READY:<id>` (the live
  relay is streaming). The stdout delivery shape is the same envelope as
  `how-to ready`.
- (v0.8.0+) `--adapter <name:profile>` resolves the registered manifest, so
  `--manifest` becomes optional once the adapter is registered.

Do NOT use `--once` for a live agent. `--once` drains the backlog and forwards
one delivery, then exits — the degenerate path for a harness that LACKS a
long-running listener. It never sustains a live session.

What makes the agent live is the manifest: it must declare
`[session.psyche_init]` (the Psyche-spawn seam). A manifest without that
section brings up a ready agent, not a live one. Liveness is
daemon-authoritative — the daemon hosts the Psyche off the perch's online
status, so the companion survives a relay restart.

Reset and reconcile: on a session boundary (a `/clear`-equivalent) re-fire the
same `spt api listen <id>` — the seed bridges the new session to the same
perch and the relay resumes.

Going offline: end the session (stop the listen process) to drop the relay;
`spt endpoint shutdown <id>` is the graceful ending — it tears the Psyche down
with the perch.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; `subnet` added M11-W5, `live` added M11-W6 (REQ-DOCS-6)
/// — high-value first-run flows previously NO_SUCH_TOPIC dead-ends. Bare `how-to`
/// lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
    (
        "subnet",
        "pair machines: create vs join, the 6-digit code, reaching remote agents",
        HOW_TO_SUBNET,
    ),
    (
        "live",
        "run as a live agent: the persistent listen relay, --once is NOT it, the Psyche seam",
        HOW_TO_LIVE,
    ),
];