@agfpd/iapeer 0.4.134 → 0.4.135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,11 +23,12 @@ Once a minute the daemon runs a supervisory pass. In one tick it sleeps agents i
23
23
 
24
24
  ## Peer states
25
25
 
26
- A claude/codex agent is, at any moment, in one of three states:
26
+ A hosted agent is, at any moment, in one of four states:
27
27
 
28
28
  | State | What it means | Marker in `iapeer list` |
29
29
  |---|---|---|
30
30
  | **alive** | the session is running and answering | `●` |
31
+ | **booting** | the supervisor is up, but this runtime hosting has not passed readiness | `◐` |
31
32
  | **asleep** | no session, the peer is ready to wake on a message | `○` |
32
33
  | **stopped** | the operator set a stop flag; the daemon won't wake it | `✕` |
33
34
 
@@ -101,6 +102,24 @@ So `stop`, `start`, `new` refuse to touch foreign launchd peers — for those th
101
102
  ### Process ownership at teardown
102
103
 
103
104
  Closing a hosted session is a **hard teardown**, not the MCP client's graceful stdin/EOF shutdown.
105
+ Core-controlled termination first fsyncs a write-ahead `termination-request` (or a multi-target
106
+ `termination-batch`) to `<supervisorRunDir>/terminations.log`, with caller identity/UID, caller PID
107
+ and parent PID, target identity/PID/start token and reason. Failure to persist the audit refuses the
108
+ signal. Stop-all, uninstall and infra-update record their scope before the first termination.
109
+ Signals from external programs bypass this primitive: `shutdown-sigterm` in `exits.log` alone does
110
+ not identify their sender. The supervisor's JavaScript signal handler cannot recover that attribution.
111
+
112
+ The supervision process namespace is bound to `LifecycleConfig.supervisorRunDir`, not re-resolved
113
+ from an invocation's environment. A partial config without that path uses a private subdirectory of
114
+ its own `stateDir`. Under `IAPEER_TEST_SANDBOX=1`, the signal primitive also refuses the real host's
115
+ supervisor run-dir (including symlink aliases).
116
+
117
+ Readiness is specific to one hosting token. A never-ready process is not deliverable, even if its
118
+ TTY echoes input. The launcher tears it down on its readiness deadline; a later message can replace
119
+ an abandoned never-ready hosting under the wake lock. Process-control verbs can still address it.
120
+ Migration of existing agent sessions checks their current host emulator (or native control ping),
121
+ not old pane-log bytes and not merely a live PID.
122
+
104
123
  Before killing the runtime, the supervisor snapshots its descendants and kills those still attached
105
124
  to its controlling PTY, including stdio MCP launchers in separate process groups. Each PID's start
106
125
  token and terminal are re-checked before signalling; unrelated processes and detached daemon branches
@@ -83,6 +83,7 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
83
83
  - **`lifecycle.log`** — lifecycle decisions: wakes, sleeps, restarts, supervisory-tick outcomes. The first place to look if a peer is behaving wrong.
84
84
  - **`delivery.log`** — the outcome of each delivery: who, to whom, into which runtime, the result, sizes, topic. Metadata only — **the message body is not written**.
85
85
  - **`exits.log`** — session deaths with postmortem data: the supervisor records the cause (exit code / signal) at the moment a session dies.
86
+ - **`~/.iapeer/state/supervisor/terminations.log`** — strict write-ahead attribution of core-controlled termination: caller/UID, PID/parent PID, target scope/PID/start token, reason. Appended and fsynced before signalling; inability to write refuses the signal. Batch scope is recorded before stop-all, uninstall and infra-update. No entry means an external signal is unattributed, not evidence of any particular initiator. This audit follows the configured supervisor run-dir, including isolated tests.
86
87
 
87
88
  Per-peer **pane-logs** live separately under `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — the raw terminal (pty) byte stream the supervisor appends for a session's whole life (occupancy/typing detection, the boot ready-gate, and `tail`-based monitors read them). They are rotated on the supervise tick by **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, default 16 MiB × 2): the base file keeps its path and inode (its last 8 MiB tail is retained in place, so the live writer and every reader are undisturbed) while the preceding window is promoted to `<log>.1` … `.N`. Total per peer is bounded by roughly `MAX_BYTES × (KEEP + 1)`.
88
89
 
@@ -77,7 +77,7 @@ The full fleet state. The peer rows are produced by the **same in-process functi
77
77
 
78
78
  Field notes:
79
79
 
80
- - `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
80
+ - `runtimes[].status` — `live` (runtime passed readiness in this hosting) · `booting` (supervisor up, runtime never reached ready) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `◐` / `○` / `✕`. `booting` is not a working session or a successful delivery; clients must not display it as `live`. Process controls can still address it. Added as a status value without changing the snapshot structure.
81
81
  - `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
82
82
  - `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
83
83
  - `runtimes[].model` — the `provider/model` the endpoint ACTUALLY used for its last assistant turn, as the runtime itself reports it (today `opencode`, from its state; docs/20 §Provider pinning). Absent until a first turn. Lets an operator see an implicit default provider. Additive.
@@ -232,6 +232,10 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
232
232
  dies with the turn unfinished, the sender gets an explicit notice from iapeer
233
233
  («the turn was INTERRUPTED and its result is not guaranteed»), logged as
234
234
  `ev=inflight-interrupted`.
235
+ Several messages injected into one unfinished hosting are summarized in one notice per original
236
+ sender endpoint, listing every affected delivery; different hosting tokens and senders remain
237
+ separate. An outbound reply alone is not a terminal boundary. Completed native turns are removed
238
+ from the ledger before notification grouping, so destroying a finished session emits no notice.
235
239
  - Unknown is not failed. When a live receiver's acceptance record does not appear
236
240
  within the confirm window (a busy claude records a queued message late), the
237
241
  send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.134",
3
+ "version": "0.4.135",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -35,6 +35,8 @@
35
35
  "bin",
36
36
  "docs",
37
37
  "scripts/proof-install-signature.ts",
38
+ "scripts/proof-fleet-isolation.ts",
39
+ "scripts/proof-inflight-turns.ts",
38
40
  "CHANGELOG.md",
39
41
  "tsconfig.json",
40
42
  "!src/**/*.test.ts",
@@ -0,0 +1,71 @@
1
+ // Live, isolated reproduction of the 18.10 test-suite SIGTERM incident.
2
+ // bun scripts/proof-fleet-isolation.ts [--expect-legacy-failure]
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { startSupervisorDaemon, killSession, sessionAlive } from '../src/supervisor/index.ts'
7
+ import { loadLifecycleConfig, superviseTick } from '../src/lifecycle/index.ts'
8
+ import { upsertPeer } from '../src/registry/index.ts'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const root = mkdtempSync(join(tmpdir(), 'iap-isolation-'))
12
+ const foreign = join(root, 'foreign')
13
+ const own = join(root, 'own')
14
+ const runDir = join(foreign, 'state/supervisor')
15
+ const env = { ...process.env, IAPEER_ROOT: foreign, IAPEER_LAUNCHAGENTS_DIR: join(own, 'LaunchAgents') }
16
+ const cfg = loadLifecycleConfig({ ...env, IAPEER_ROOT: own, IAPEER_BOOT_DEADLINE_SECS: '1', IAPEER_READY_GATE_SECS: '1' })
17
+ const ids = ['claude-sentinel', 'telegram-sentinel', 'notifier-sentinel']
18
+ const arg = (name: string) => { const i = process.argv.indexOf(name); return i < 0 ? undefined : process.argv[i + 1] }
19
+ const bin = arg('--bin')
20
+ const artifacts = arg('--artifacts')
21
+ const pids: Record<string, number | undefined> = {}
22
+ const legacyFile = fileURLToPath(new URL(`../src/lifecycle/.proof-legacy-${process.pid}.ts`, import.meta.url))
23
+ mkdirSync(cfg.stateDir, { recursive: true })
24
+ try {
25
+ for (const session of ids) {
26
+ const r = await startSupervisorDaemon({ session, runtime: session.split('-')[0]!, runDir,
27
+ ...(bin ? { daemonArgv: [bin, 'supervisor'] } : {}),
28
+ serve: { argv: ['/bin/sleep', '600'], env: {}, cwd: root, exitLogPath: join(root, 'exits.log') } })
29
+ if (r.state !== 'started') throw new Error(JSON.stringify(r))
30
+ pids[session] = r.pid
31
+ }
32
+ // Exactly the problematic shape: cfg is isolated, env points at another fleet; no liveness seam.
33
+ let sweep = superviseTick
34
+ if (process.argv.includes('--replay-legacy-resolver')) {
35
+ // Re-run the actual sweep with ONLY its namespace selection restored to the incident's env
36
+ // resolution. Both fleets are created above, never supplied by the caller. Audit stays enabled.
37
+ const source = readFileSync(fileURLToPath(new URL('../src/lifecycle/index.ts', import.meta.url)), 'utf8')
38
+ const fixed = "const runDir = cfg.supervisorRunDir ?? join(cfg.stateDir, 'supervisor')"
39
+ if (!source.includes(fixed)) throw new Error('legacy replay patch no longer matches source')
40
+ writeFileSync(legacyFile, "import { hostRunDir } from '../launch/ptyHost.ts'\n" + source.replace(fixed, 'const runDir = hostRunDir(env)'))
41
+ sweep = (await import(legacyFile)).superviseTick
42
+ }
43
+ const outcomes = sweep(cfg, { env, nowMs: Date.now() + 3600_000 })
44
+ await Bun.sleep(1500)
45
+ const dead = ids.filter(id => !sessionAlive(runDir, id))
46
+ const read = (path: string) => { try { return readFileSync(path, 'utf8') } catch { return '' } }
47
+ const result: Record<string, unknown> = { pids, outcomes, foreignKilled: dead, exits: read(join(root, 'exits.log')) }
48
+ if (process.argv.includes('--expect-legacy-failure') ? dead.length !== ids.length : dead.length !== 0) throw new Error('isolation invariant failed')
49
+ if (bin && dead.length === 0) {
50
+ await upsertPeer({ personality: 'sentinel', runtime: 'claude', runtimes: ['claude', 'telegram', 'notifier'], cwd: root, intelligence: 'artificial' }, { rootDir: foreign })
51
+ const stop = Bun.spawn([bin, 'stop', 'sentinel', 'claude'], { env: { ...env, PEER_IDENTITY: 'test-operator' }, stdout: 'pipe', stderr: 'pipe' })
52
+ const code = await stop.exited
53
+ result.stop = { code, stdout: await new Response(stop.stdout).text(), stderr: await new Response(stop.stderr).text() }
54
+ await Bun.sleep(1000)
55
+ result.afterTargetedStop = Object.fromEntries(ids.map(id => [id, sessionAlive(runDir, id)]))
56
+ if (code || sessionAlive(runDir, ids[0]!) || ids.slice(1).some(id => !sessionAlive(runDir, id))) throw new Error('installed stop affected foreign endpoint')
57
+ }
58
+ result.audit = read(join(runDir, 'terminations.log'))
59
+ result.exits = read(join(root, 'exits.log'))
60
+ if (artifacts) {
61
+ mkdirSync(artifacts, { recursive: true })
62
+ writeFileSync(join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n')
63
+ }
64
+ console.log(JSON.stringify(result, null, 2))
65
+ console.log(dead.length ? 'REPRODUCED: cfg-only sandbox killed foreign supervisors' : 'PASS: foreign supervisors survived the real sweep')
66
+ } finally {
67
+ for (const id of ids) killSession(runDir, id)
68
+ await Bun.sleep(1000)
69
+ rmSync(root, { recursive: true, force: true })
70
+ rmSync(legacyFile, { force: true })
71
+ }
@@ -0,0 +1,89 @@
1
+ // Real OpenCode + synthetic streaming provider, isolated root. No cloud calls or fleet traffic.
2
+ // OPENCODE_BIN=... bun scripts/proof-inflight-turns.ts --seed-config <opencode config dir> [--keep]
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ const bin = process.env.OPENCODE_BIN
6
+ if (!bin || !existsSync(bin)) throw new Error('set OPENCODE_BIN')
7
+ const root = mkdtempSync('/tmp/iap-inflight-live-')
8
+ const home = join(root, 'home')
9
+ const env = { PATH: process.env.PATH, HOME: home, IAPEER_ROOT: join(root, 'iapeer'), IAPEER_PORT: '9',
10
+ IAPEER_OPENCODE_BIN: bin, XDG_CONFIG_HOME: join(home, 'config'), XDG_DATA_HOME: join(home, 'data'),
11
+ XDG_CACHE_HOME: join(home, 'cache'), XDG_STATE_HOME: join(home, 'state'),
12
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true', HOMEBREW_NO_AUTO_UPDATE: '1' }
13
+ mkdirSync(home, { recursive: true })
14
+ const seed = process.argv.indexOf('--seed-config')
15
+ if (seed >= 0) cpSync(process.argv[seed + 1]!, join(home, 'config/opencode'), { recursive: true })
16
+ for (const k of Object.keys(process.env)) delete process.env[k]
17
+ Object.assign(process.env, env)
18
+ const { initPeer } = await import('../src/init/index.ts')
19
+ const { loadLifecycleConfig, wakeOrSpawn } = await import('../src/lifecycle/index.ts')
20
+ const { routeSend, deliveryProcessed, deliveryCompleted } = await import('../src/transport/index.ts')
21
+ const { makeNoteInflight, processInflight } = await import('../src/daemon/main.ts')
22
+ const { readInflight } = await import('../src/lifecycle/inflight.ts')
23
+ const { opencodePaths } = await import('../src/opencode/config.ts')
24
+ const { killPtyHost, hostSessionAlive } = await import('../src/launch/ptyHost.ts')
25
+ const { ok } = await import('../src/core/errors.ts')
26
+ const cfg = loadLifecycleConfig(env)
27
+ const personality = 'turn-proof', identity = `opencode-${personality}`, cwd = join(root, personality)
28
+ mkdirSync(cwd, { recursive: true })
29
+ let hanging = false
30
+ const provider = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
31
+ await req.json()
32
+ const chunk = (delta: unknown, finish: string | null) => 'data: ' + JSON.stringify({ id: 'proof', object: 'chat.completion.chunk', created: 1, model: 'synthetic', choices: [{ index: 0, delta, finish_reason: finish }] }) + '\n\n'
33
+ const initial = chunk({ role: 'assistant', content: hanging ? 'STILL WORKING' : 'FINISHED' }, null)
34
+ return new Response(hanging ? new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(initial)) } }) : initial + chunk({}, 'stop') + 'data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream' } })
35
+ } })
36
+ const notices: string[] = []
37
+ const sink = (async (_caller: unknown, input: { message: string; personality: string }) => {
38
+ notices.push(input.message)
39
+ return ok({ ok: true, delivered_to: { personality: input.personality, runtime: 'codex' }, woke: false, ts: 'x' })
40
+ }) as never
41
+ const until = async (what: string, f: () => boolean) => {
42
+ for (let i = 0; i < 300; i++) { if (f()) return; await Bun.sleep(100) }
43
+ throw new Error(`timeout: ${what}`)
44
+ }
45
+ const snapshots: unknown[] = []
46
+ let success = false
47
+ try {
48
+ await initPeer({ cwd, runtime: 'opencode', personality, intelligence: 'artificial', env })
49
+ const paths = opencodePaths(personality, { env })
50
+ mkdirSync(paths.dir, { recursive: true })
51
+ writeFileSync(paths.config, JSON.stringify({ model: 'synthetic/synthetic', small_model: 'synthetic/synthetic', enabled_providers: ['synthetic'], provider: {
52
+ synthetic: { npm: '@ai-sdk/openai-compatible', name: 'Synthetic', options: { baseURL: provider.url.toString() + 'v1', apiKey: 'x' }, models: { synthetic: { name: 'Synthetic', limit: { context: 100000, output: 1000 } } } },
53
+ } }))
54
+ const caller = { personality: 'sender', runtime: 'codex', address: 'codex-sender', intelligence: 'artificial', cwd, description: '', record: { personality: 'sender', runtime: 'codex', runtimes: ['codex'], intelligence: 'artificial', cwd } } as never
55
+ const deps = { env, wake: (args: never) => wakeOrSpawn(args, { cfg, env }), noteInflight: makeNoteInflight(cfg) }
56
+ const send = async (message: string) => {
57
+ const r = await routeSend(caller, { personality, runtime: 'opencode', message }, deps)
58
+ if (!r.ok) throw r.error
59
+ return r.value
60
+ }
61
+ snapshots.push({ phase: 'finished-send', ack: await send('FINISHED_TURN_PROOF') })
62
+ await until('native terminal record', () => readInflight(cfg.stateDir, identity).some(e => deliveryCompleted(e, env)))
63
+ snapshots.push({ phase: 'native-completed', entries: readInflight(cfg.stateDir, identity).map(e => ({ id: e.id, processed: deliveryProcessed(e, env), completed: deliveryCompleted(e, env) })) })
64
+ // Do not sweep before killing: completion must still be recognized after the receiver is gone.
65
+ killPtyHost(identity, env, { reason: 'proof-kill-after-native-completion' })
66
+ await until('finished receiver gone', () => !hostSessionAlive(identity, env))
67
+ await processInflight(cfg, env, deps, { send: sink })
68
+ if (notices.length || readInflight(cfg.stateDir, identity).length) throw new Error('finished turn generated notice or retained ledger')
69
+ snapshots.push({ phase: 'kill-after-finish', noticeCount: notices.length })
70
+
71
+ hanging = true
72
+ snapshots.push({ phase: 'mid-send', ack: await send('UNFINISHED_TURN_PROOF') })
73
+ await until('native assistant starts without terminal', () => readInflight(cfg.stateDir, identity).some(e => deliveryProcessed(e, env) && !deliveryCompleted(e, env)))
74
+ snapshots.push({ phase: 'native-unfinished', entries: readInflight(cfg.stateDir, identity).map(e => ({ id: e.id, processed: deliveryProcessed(e, env), completed: deliveryCompleted(e, env) })) })
75
+ killPtyHost(identity, env, { reason: 'proof-kill-during-native-stream' })
76
+ await until('unfinished receiver gone', () => !hostSessionAlive(identity, env))
77
+ await processInflight(cfg, env, deps, { send: sink })
78
+ await processInflight(cfg, env, deps, { send: sink })
79
+ if (notices.length !== 1 || !notices[0]!.includes('INTERRUPTED')) throw new Error(`expected one notice, got ${notices.length}`)
80
+ snapshots.push({ phase: 'kill-mid-turn-two-sweeps', noticeCount: notices.length, notices })
81
+ success = true
82
+ } finally {
83
+ killPtyHost(identity, env, { reason: 'proof-cleanup' })
84
+ provider.stop(true)
85
+ writeFileSync(join(root, 'result.json'), JSON.stringify({ success, codeSource: import.meta.url, snapshots }, null, 2))
86
+ console.log(JSON.stringify({ root, success, snapshots }, null, 2))
87
+ await Bun.sleep(300)
88
+ if (success && !process.argv.includes('--keep')) rmSync(root, { recursive: true, force: true })
89
+ }
package/src/cli/index.ts CHANGED
@@ -26,9 +26,11 @@ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaem
26
26
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
27
27
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
28
28
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
29
+ import { auditTermination } from '../supervisor/index.ts'
29
30
  import {
30
31
  compactDoneBaseline,
31
32
  isPeerLive,
33
+ isPeerReady,
32
34
  purgeAttachmentInbox,
33
35
  routeControl,
34
36
  routeSend,
@@ -106,7 +108,9 @@ function infraRecycleFailed(infra: readonly { state: string }[] | undefined): bo
106
108
  // list — registry + per-runtime liveness (contract Примитивы §list)
107
109
  // ─────────────────────────────────────────────────────────────────────────────
108
110
 
109
- export type RuntimeLiveness = 'live' | 'asleep' | 'stopped'
111
+ /** `booting` — the process is up but its runtime never reached its ready surface (yet, or ever: a
112
+ * process hung before main, 18.10). Not deliverable; not ●. */
113
+ export type RuntimeLiveness = 'live' | 'booting' | 'asleep' | 'stopped'
110
114
  export interface RuntimeStatus {
111
115
  runtime: Runtime
112
116
  status: RuntimeLiveness
@@ -145,9 +149,11 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
145
149
  return index.peers.map(peer => {
146
150
  const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
147
151
  runtime: rt,
148
- status: isPeerLive(rt, peer.personality, cfg.sockDir, env)
152
+ status: isPeerReady(rt, peer.personality, env)
149
153
  ? 'live'
150
- : isStopped(cfg, buildProcessAddress(rt, peer.personality))
154
+ : isPeerLive(rt, peer.personality, cfg.sockDir, env)
155
+ ? 'booting'
156
+ : isStopped(cfg, buildProcessAddress(rt, peer.personality))
151
157
  ? 'stopped'
152
158
  : 'asleep',
153
159
  }))
@@ -177,7 +183,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
177
183
  })
178
184
  }
179
185
 
180
- const GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
186
+ const GLYPH: Record<RuntimeLiveness, string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
181
187
 
182
188
  /** Render the scriptable list table (non-tty default). Columns are PADDED to align
183
189
  * (name · [default] · nature · liveness) — a ragged table reads as noise. The
@@ -260,7 +266,9 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
260
266
  return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not stop it` }]
261
267
  }
262
268
  const out: StopStartOutcome[] = []
263
- for (const rt of targetRuntimes(peer, runtime)) {
269
+ const targets = targetRuntimes(peer, runtime)
270
+ auditTermination(hostRunDir(env), targets.map(rt => buildProcessAddress(rt, personality)), { reason: 'stop', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
271
+ for (const rt of targets) {
264
272
  const identity = buildProcessAddress(rt, personality)
265
273
  const sock = buildSocketPath(rt, personality, cfg.sockDir)
266
274
  if (isInfraRuntime(rt)) {
@@ -276,7 +284,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
276
284
  // below still tears the session down.
277
285
  const target = resolveAlwaysOnTarget(personality, rt, env)
278
286
  const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${target.label}`], { encoding: 'utf8' })
279
- killSession(sock, identity, env)
287
+ killSession(sock, identity, env, 'stop')
280
288
  const stderrText = (r.stderr ?? '').trim()
281
289
  const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
282
290
  const reason = benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`
@@ -290,7 +298,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
290
298
  // no reaped-gone death class for a state the daemon knows 100%).
291
299
  setStopped(cfg, identity)
292
300
  setIdleReaped(cfg, identity)
293
- killSession(sock, identity, env)
301
+ killSession(sock, identity, env, 'stop')
294
302
  removeSessionState(cfg, identity)
295
303
  out.push({ personality, runtime: rt, action: 'stopped' })
296
304
  // Durable trace (fleet-API follow-up): the verb was SILENT in lifecycle.log — a
@@ -674,7 +682,7 @@ export async function newPeer(
674
682
  clearStopped(cfg, identity)
675
683
  clearIdleReaped(cfg, identity)
676
684
  clearNewEager(cfg, identity)
677
- killSession(sock, identity, env) // canary-clean deliberate teardown; no-op when dead
685
+ killSession(sock, identity, env, 'new') // canary-clean deliberate teardown; no-op when dead
678
686
  removeSessionState(cfg, identity) // never a death class — supervise stays silent
679
687
  const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
680
688
  const r = await wake({ personality, runtime: rt, task: '', resume: false })
@@ -961,7 +969,7 @@ export async function removeRuntimeCli(
961
969
  return { personality, runtime, action: 'refused-live', reason: `${identity} is LIVE — stop it first (iapeer stop ${personality} ${runtime}) or pass --force` }
962
970
  }
963
971
  const { killPtyHost } = await import('../launch/ptyHost.ts')
964
- killPtyHost(identity, env)
972
+ killPtyHost(identity, env, { reason: 'remove-runtime-force' })
965
973
  steps.push('live session stopped (--force)')
966
974
  }
967
975
  // Slots for THIS endpoint (provider + capabilities), while the peer's state is still consistent.
@@ -1114,6 +1122,7 @@ export async function removePeerCli(
1114
1122
  if (env.IAPEER_TEST_SANDBOX === '1') {
1115
1123
  bootoutNote = 'skipped-sandbox'
1116
1124
  } else {
1125
+ auditTermination(hostRunDir(env), [entry.label], { reason: 'remove', caller: env.PEER_IDENTITY })
1117
1126
  const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${entry.label}`], { encoding: 'utf8' })
1118
1127
  // bootout exits non-zero when the job was already unloaded — benign for a teardown.
1119
1128
  bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
@@ -2431,6 +2440,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2431
2440
  ? [positionals[0]]
2432
2441
  : null
2433
2442
  if (!peers) return argErr(errOut, 'stop needs a peer or --all — usage: iapeer stop <peer> [runtime] | --all')
2443
+ if (flags.all === true) auditTermination(hostRunDir(env), peers, { reason: 'stop-all', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
2434
2444
  const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
2435
2445
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
2436
2446
  // В48 — a REAL bootout error (reason set; benign already-unloaded carries none) is exit 1:
@@ -3022,7 +3032,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
3022
3032
  }
3023
3033
  out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
3024
3034
  const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
3025
- killSession(sock, identity, env)
3035
+ killSession(sock, identity, env, 'self-fresh')
3026
3036
  return 0
3027
3037
  }
3028
3038
  case 'interrupt': {
@@ -12,7 +12,7 @@ import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the su
12
12
  import { runSupervisorClient } from '../supervisor/client.ts'
13
13
  import { listPeers, type PeerListing } from './index.ts'
14
14
 
15
- const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
15
+ const GLYPH: Record<'live' | 'booting' | 'asleep' | 'stopped', string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
16
16
 
17
17
  // ─── pure render ─────────────────────────────────────────────────────────────
18
18
 
@@ -82,7 +82,8 @@ export const FLEET_API_VERSION = 1
82
82
 
83
83
  export interface FleetRuntimeStatus {
84
84
  runtime: string
85
- status: 'live' | 'asleep' | 'stopped'
85
+ /** `booting` — process up, runtime not (yet / ever) ready: not deliverable (18.10). */
86
+ status: 'live' | 'booting' | 'asleep' | 'stopped'
86
87
  /** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
87
88
  * agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
88
89
  * `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
@@ -336,9 +336,22 @@ export async function processInflight(
336
336
  // A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
337
337
  // effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
338
338
  for (const identity of listInterruptedIdentities(cfg.stateDir)) {
339
+ // Multiple messages can be injected into ONE still-running turn. A session death must notify each
340
+ // affected sender once, not fan out one alert per injection (18.10: twenty alerts from one turn).
341
+ // Keep different hosting tokens and callers separate; no message is silently discarded: the one
342
+ // notice enumerates the affected deliveries. Completed entries have already left in the sweep.
343
+ const groups = new Map<string, InflightEntry[]>()
339
344
  for (const e of takeInterrupted(cfg.stateDir, identity)) {
340
- const notified = await notifySender(e, interruptedNotice(e))
341
- appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-interrupted', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', notified })
345
+ const key = JSON.stringify([e.sessionToken, e.caller.runtime, e.caller.personality])
346
+ const entries = groups.get(key) ?? []
347
+ entries.push(e)
348
+ groups.set(key, entries)
349
+ }
350
+ for (const entries of groups.values()) {
351
+ const e = entries[0]!
352
+ const summary = entries.length > 1 ? `\nAffected deliveries (${entries.length}):\n` + entries.map(x => `${x.sentAt}${x.topic ? ` topic=${x.topic}` : ''}: ${x.message.split('\n')[0]!.slice(0, 200)}`).join('\n') : ''
353
+ const notified = await notifySender(e, interruptedNotice(e) + summary)
354
+ appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-interrupted', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', notified, count: entries.length })
342
355
  }
343
356
  }
344
357
  for (const identity of listOrphanIdentities(cfg.stateDir)) {
@@ -470,6 +483,14 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
470
483
  // Notice board (docs/19) — ONE instance shared by the mute-watch (raises) and the
471
484
  // fleet handler (serves /notices + the snapshot field). Constructed HERE, at the
472
485
  // composition point, because that sharing is the whole wiring.
486
+ // Readiness marker compatibility (18.10): sessions already running when this core first starts were
487
+ // launched without a ready marker — stamp them ONCE so delivery does not read the healthy fleet as
488
+ // booting. Every later hosting writes its own marker at its ready-gate.
489
+ try {
490
+ const { backfillHostReadyOnce } = await import('../launch/ptyHost.ts')
491
+ const stamped = await backfillHostReadyOnce(env)
492
+ if (stamped) appendLifecycleEvent(cfg.eventLogDir, { ev: 'ready-backfill', count: stamped.length }, { env })
493
+ } catch { /* best-effort */ }
473
494
  const board = new NoticeBoard({ logDir: cfg.eventLogDir, env })
474
495
  const stopMuteWatch = startMuteWatch(cfg, board, {
475
496
  env,
@@ -17,7 +17,7 @@ import { existsSync, mkdirSync, statSync } from 'fs'
17
17
  import { dirname } from 'path'
18
18
  import { type Runtime } from '../core/constants.ts'
19
19
  import { buildLaunchInvocation } from './invocation.ts'
20
- import { deliverHosted, hostSessionAlive, startPtyHost, waitHostReady } from './ptyHost.ts'
20
+ import { deliverHosted, hostSessionAlive, hostSessionToken, killPtyHost, markHostReady, startPtyHost, waitHostReady } from './ptyHost.ts'
21
21
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
22
22
  import { claudeAdapter } from './adapters/claude.ts'
23
23
  import { codexAdapter } from './adapters/codex.ts'
@@ -236,15 +236,24 @@ export const launch: LaunchFn = async (
236
236
  // router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
237
237
  // no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
238
238
  if (adapter.kind === 'router') {
239
- if (hostSessionAlive(identity, env)) return ready(identity)
239
+ if (hostSessionAlive(identity, env)) {
240
+ markHostReady(identity, env) // a router is up the instant its child serves
241
+ return ready(identity)
242
+ }
240
243
  return fail(identity, 'hosted router session not alive immediately after bringup')
241
244
  }
245
+ const hostingToken = hostSessionToken(identity, env)
242
246
  // STRUCTURAL runtime (opencode): readiness = the runtime's own ready record for THIS launch, and
243
247
  // the first message goes through the adapter's deliver, whose ack means THIS message is persisted
244
248
  // natively (a durable native queue) — so there is no pane gate and no activity ready-gate.
245
249
  if (adapter.structuralReady && readyToken) {
246
250
  const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
247
- if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
251
+ if (!r.ok) {
252
+ // A bring-up that never reached ready must not linger as a live-looking host (incident 18.10).
253
+ killPtyHost(identity, env, { reason: 'launch-structural-never-ready' })
254
+ return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
255
+ }
256
+ markHostReady(identity, env, hostingToken)
248
257
  for (const w of r.warnings ?? []) {
249
258
  if (!loggedWarnings.has(w)) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
250
259
  }
@@ -261,7 +270,15 @@ export const launch: LaunchFn = async (
261
270
  { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
262
271
  sleep,
263
272
  )
264
- if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
273
+ if (!isReady) {
274
+ // Tear the host down: a process that never painted its input surface (hung before main —
275
+ // codex in dyld, 18.10) otherwise lived on under a healthy supervisor, read as ● by list and
276
+ // «live» by delivery, and swallowed a message with a false ok. Gone, the ledger redelivers its
277
+ // entries to the next session.
278
+ killPtyHost(identity, env, { reason: 'launch-never-ready' })
279
+ return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
280
+ }
281
+ markHostReady(identity, env, hostingToken)
265
282
  if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
266
283
  // В18 — snap the ready-gate baseline AFTER boot, not before. newestActivityMtime is a raw
267
284
  // transcript-file mtime that BOOT ITSELF advances: codex writes its rollout-jsonl on start,
@@ -31,6 +31,8 @@ import {
31
31
  import { buildProcessAddress } from '../core/socket.ts'
32
32
  import { peerLogsDir } from '../storage/index.ts'
33
33
  import { IapError } from '../core/errors.ts'
34
+ import { auditTermination } from '../supervisor/index.ts'
35
+ import { defaultRunDir } from '../supervisor/paths.ts'
34
36
 
35
37
  const DEFAULT_THROTTLE_SECS = 10
36
38
 
@@ -556,6 +558,7 @@ export function cycleLaunchdJob(
556
558
  const uid = currentUid()
557
559
  return cycleLaunchdJobCore(uid, label, plistPath, {
558
560
  run: args => {
561
+ if (args[0] === 'bootout') auditTermination(defaultRunDir(env), [label], { reason: 'launchd-cycle', caller: env.PEER_IDENTITY })
559
562
  const r = spawnSync('launchctl', args, { encoding: 'utf8' })
560
563
  return { status: r.status, stderr: r.stderr ?? '' }
561
564
  },
@@ -610,6 +613,7 @@ export function bootoutLaunchdJob(label: string, env: NodeJS.ProcessEnv = proces
610
613
  }
611
614
  const uid = currentUid()
612
615
  if (!isLaunchdLoaded(label, uid)) return { state: 'not-loaded' }
616
+ auditTermination(defaultRunDir(env), [label], { reason: 'launchd-bootout', caller: env.PEER_IDENTITY })
613
617
  const r = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' })
614
618
  return r.status === 0 ? { state: 'booted-out' } : { state: 'failed', detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
615
619
  }
@@ -165,7 +165,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
165
165
  // kill the supervisor daemon (SIGTERM → hard teardown of its PTY-owned tree + sock/pid/serve cleanup)
166
166
  // so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
167
167
  // (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
168
- if (stop) killPtyHost(identity, env)
168
+ if (stop) killPtyHost(identity, env, { reason: 'run-infra-shutdown' })
169
169
  return 0
170
170
  }
171
171
 
@@ -10,11 +10,12 @@
10
10
  // - the deliver client is a pure-protocol leaf (no @xterm);
11
11
  // - sessionAlive comes from client.ts (no @xterm);
12
12
  // - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
13
- import { existsSync, readFileSync } from 'node:fs'
13
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
14
15
  import { deliverToHost, sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
15
16
  import { attachedPath, defaultRunDir, pidPath, readGeometry } from '../supervisor/paths.ts'
16
17
  import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
17
- import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
18
+ import { killSession as killSupervisorSession, listSessions as listSupervisorSessions, type TerminationContext } from '../supervisor/index.ts'
18
19
  import { parseSessionName } from '../core/socket.ts'
19
20
  import type { TmuxRuntime } from '../core/constants.ts'
20
21
  import { paneLogViewport } from './readyGateModel.ts'
@@ -45,10 +46,83 @@ export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = proc
45
46
  return hostDaemonAlive(hostRunDir(env), identity)
46
47
  }
47
48
 
49
+ // ─── Runtime READINESS (incident 18.10) ──────────────────────────────────────
50
+ // «Alive» (the supervisor daemon's pid) is not «the runtime is up». A codex process hung before main
51
+ // (dyld, after a cask update) lived under a healthy supervisor for 35 min: `list` showed ●, a send
52
+ // went «live» and was confirmed ok (the cooked tty echoed the paste into the pane-log), and the ledger
53
+ // held the message as in-progress. The invariant: a session that never reached its runtime's ready
54
+ // surface is NOT live for delivery/list. Operator controls still address the hung process.
55
+ // The launch writes this marker (the hosting token) the moment its ready-gate passes; a marker from
56
+ // another hosting (a replaced session) does not count.
57
+
58
+ function readyPath(identity: string, env: NodeJS.ProcessEnv): string {
59
+ return join(hostRunDir(env), `${identity}.ready`)
60
+ }
61
+
62
+ /** Record that THIS hosting of the identity reached its runtime's ready surface. */
63
+ export function markHostReady(identity: string, env: NodeJS.ProcessEnv = process.env, token = hostSessionToken(identity, env)): void {
64
+ if (!token || token !== hostSessionToken(identity, env)) throw new Error(`hosting changed before ready: ${identity}`)
65
+ writeFileSync(readyPath(identity, env), `${token}\n`, { mode: 0o600 })
66
+ }
67
+
68
+ export function clearHostReady(identity: string, env: NodeJS.ProcessEnv = process.env): void {
69
+ rmSync(readyPath(identity, env), { force: true })
70
+ }
71
+
72
+ /** When this hosting started (its supervisor pid-file mtime), or null. */
73
+ export function hostStartedMs(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): number | null {
74
+ try {
75
+ return statSync(pidPath(runDir, identity)).mtimeMs
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Alive AND this very hosting reached ready. */
82
+ export function hostSessionReady(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): boolean {
83
+ if (!hostDaemonAlive(runDir, identity)) return false
84
+ const token = hostSessionToken(identity, env, runDir)
85
+ try {
86
+ return !!token && readFileSync(join(runDir, `${identity}.ready`), 'utf8').trim() === token
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * One-time compatibility for sessions that were already running when the readiness marker was
94
+ * introduced. Prove readiness in each current host, without trusting historical pane-log frames.
95
+ */
96
+ export async function backfillHostReady(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
97
+ const out: string[] = []
98
+ for (const h of listHostedPeers(env)) {
99
+ const id = `${h.runtime}-${h.personality}`
100
+ if (existsSync(readyPath(id, env))) continue
101
+ const token = hostSessionToken(id, env)
102
+ // Migration must prove readiness too: blindly stamping legacy pids re-labels a dyld hang as live.
103
+ const { getAdapter } = await import('./index.ts')
104
+ const adapter = getAdapter(h.runtime)
105
+ if (h.runtime === 'opencode') {
106
+ const { opencodeControl } = await import('../opencode/control.ts')
107
+ const { opencodePaths } = await import('../opencode/config.ts')
108
+ const ping = await opencodeControl(join(opencodePaths(h.personality, { env }).dir, 'control.sock'), { op: 'ping' }, 1000)
109
+ if (!ping.ok) continue
110
+ } else if (adapter.kind !== 'router') {
111
+ const { captureHostViewport } = await import('../supervisor/capture.ts')
112
+ const view = await captureHostViewport(hostRunDir(env), id)
113
+ if (!view || !adapter.isInputReady(view, 'gated')) continue
114
+ }
115
+ markHostReady(id, env, token)
116
+ out.push(id)
117
+ }
118
+ return out
119
+ }
120
+
48
121
  /** Reap a hosted session: SIGTERM the supervisor daemon (hard-kills its PTY-owned tree + cleans up).
49
122
  * env sandboxes the run-dir: a test with an injected root must NEVER SIGTERM a real fleet peer. */
50
- export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env): void {
51
- killSupervisorSession(hostRunDir(env), identity)
123
+ export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env, context: TerminationContext = {}, runDir = hostRunDir(env)): void {
124
+ killSupervisorSession(runDir, identity, { ...context, sandbox: context.sandbox || env.IAPEER_TEST_SANDBOX === '1',
125
+ caller: context.caller ?? env.PEER_IDENTITY ?? (env.PEER_PERSONALITY ? `${env.PEER_RUNTIME ?? 'unknown'}-${env.PEER_PERSONALITY}` : undefined) })
52
126
  }
53
127
 
54
128
  /**
@@ -78,9 +152,9 @@ export function hostGeometry(identity: string, env: NodeJS.ProcessEnv = process.
78
152
  * session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
79
153
  * `session_id:pane_id` token. null when no daemon pid is readable.
80
154
  */
81
- export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env): string | null {
155
+ export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): string | null {
82
156
  try {
83
- const pid = readFileSync(pidPath(hostRunDir(env), identity), 'utf8').trim()
157
+ const pid = readFileSync(pidPath(runDir, identity), 'utf8').trim()
84
158
  return pid ? `host:${pid}` : null
85
159
  } catch {
86
160
  return null
@@ -93,9 +167,9 @@ export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = proc
93
167
  * scan (and the no-runtime delivery resolve) include hosted peers. Empty + cheap when flag-off (the
94
168
  * run-dir is empty).
95
169
  */
96
- export function listHostedPeers(env: NodeJS.ProcessEnv = process.env): Array<{ runtime: TmuxRuntime; personality: string }> {
170
+ export function listHostedPeers(env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): Array<{ runtime: TmuxRuntime; personality: string }> {
97
171
  const out: Array<{ runtime: TmuxRuntime; personality: string }> = []
98
- for (const s of listSupervisorSessions(hostRunDir(env))) {
172
+ for (const s of listSupervisorSessions(runDir)) {
99
173
  if (!s.alive) continue
100
174
  const p = parseSessionName(s.session)
101
175
  if (p) out.push({ runtime: p.runtime, personality: p.personality })
@@ -206,3 +280,17 @@ export function deliverHosted(identity: string, message: string, env: NodeJS.Pro
206
280
  export function resubmitHosted(identity: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
207
281
  return sendControlToHost(hostRunDir(env), identity, [Buffer.from('\r', 'utf8')])
208
282
  }
283
+
284
+ /** Run backfillHostReady exactly once per host (sentinel in the run-dir). Every later hosting is a
285
+ * launch that writes its own marker, so a repeat backfill could only mask a never-ready host. */
286
+ export async function backfillHostReadyOnce(env: NodeJS.ProcessEnv = process.env): Promise<string[] | null> {
287
+ const sentinel = join(hostRunDir(env), 'ready-backfill.v1')
288
+ if (existsSync(sentinel)) return null
289
+ const stamped = await backfillHostReady(env)
290
+ try {
291
+ writeFileSync(sentinel, `${new Date().toISOString()} ${stamped.join(',')}\n`, { mode: 0o600 })
292
+ } catch {
293
+ /* best-effort */
294
+ }
295
+ return stamped
296
+ }
@@ -51,7 +51,8 @@ import {
51
51
  type LaunchSpec,
52
52
  } from '../launch/index.ts'
53
53
  import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
54
- import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
54
+ import { hostSessionAlive, hostSessionReady, hostStartedMs, killPtyHost, listHostedPeers } from '../launch/ptyHost.ts'
55
+ import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
55
56
  import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
56
57
 
57
58
  // ─────────────────────────────────────────────────────────────────────────────
@@ -59,6 +60,8 @@ import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
59
60
  // ─────────────────────────────────────────────────────────────────────────────
60
61
 
61
62
  export interface LifecycleConfig {
63
+ /** Process-control namespace, bound when cfg is loaded; never re-resolve it from deps.env. */
64
+ supervisorRunDir?: string
62
65
  claudeBin: string
63
66
  codexBin: string
64
67
  /** OpenCode native binary (docs/20). Optional so hand-built test configs stay valid. */
@@ -110,6 +113,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
110
113
  return Number.isFinite(n) && n > 0 ? n : dflt
111
114
  }
112
115
  return {
116
+ supervisorRunDir: join(root, STATE_DIR, 'supervisor'),
113
117
  claudeBin: env.IAPEER_CLAUDE_BIN ?? join(home, '.local', 'bin', 'claude'),
114
118
  codexBin: env.IAPEER_CODEX_BIN ?? 'codex',
115
119
  // The official installer's location is not on launchd's PATH; prefer it over a bare name.
@@ -1096,6 +1100,17 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1096
1100
  }
1097
1101
  // Idempotent fast path inside the lock: a live session wins (a concurrent
1098
1102
  // wake already brought it up) — no second spawn.
1103
+ // ALIVE but NOT READY while we hold the wake lock = no bring-up is in flight for it, yet it never
1104
+ // reached its ready surface (a hung-before-main process, an abandoned bring-up). Not a session —
1105
+ // replace it rather than report READY into a process that will never take input (18.10).
1106
+ if (sessionAlive(sock, identity, env) && !hostSessionReady(identity, env)) {
1107
+ killPtyHost(identity, env, { reason: 'wake-replace-never-ready' })
1108
+ logWake({ identity, runtime, mode: 'replace', cause: 'alive-never-ready' })
1109
+ // SIGTERM is asynchronous — wait (bounded) for the host to be gone, so the fast path below does
1110
+ // not read the dying process as the live winner.
1111
+ for (let i = 0; i < 25 && sessionAlive(sock, identity, env); i++) await new Promise(r => setTimeout(r, 200))
1112
+ if (sessionAlive(sock, identity, env)) return { status: 'FAILED', woke: false, runtime, reason: 'never-ready host did not terminate; retry after recovery' }
1113
+ }
1099
1114
  if (sessionAlive(sock, identity, env)) {
1100
1115
  writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
1101
1116
  // taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
@@ -1302,12 +1317,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1302
1317
  // Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
1303
1318
  // ─────────────────────────────────────────────────────────────────────────────
1304
1319
 
1305
- export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
1320
+ export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env, reason = 'lifecycle-kill'): void {
1306
1321
  // pty-only: SIGTERM the supervisor daemon — its shutdown kills the PTY-owned tree and removes the
1307
1322
  // sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
1308
1323
  // call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
1309
1324
  // injected root must NEVER SIGTERM a real fleet peer's supervisor.
1310
- killPtyHost(identity, env)
1325
+ killPtyHost(identity, env, { reason })
1311
1326
  }
1312
1327
 
1313
1328
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1348,6 +1363,9 @@ export interface SuperviseDeps {
1348
1363
  /** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
1349
1364
  * verdict so the idle path is exercised hermetically — no live pty session needed. */
1350
1365
  sessionAlive?: (sock: string, identity: string) => boolean
1366
+ /** Readiness seam (default: launch's ready marker for THIS hosting; with only sessionAlive injected,
1367
+ * ready ≡ alive). */
1368
+ sessionReady?: (identity: string) => boolean
1351
1369
  /** Reap seam (default: the real `killSession` = SIGTERM the supervisor daemon). Tests inject a no-op
1352
1370
  * so exercising the reap path NEVER SIGTERMs a real process — the destructive-kill isolation guard. */
1353
1371
  killSession?: (sock: string, identity: string) => void
@@ -1361,7 +1379,9 @@ interface SuperviseCtx {
1361
1379
  lastTurnMt: (runtime: string, cwd: string, personality?: string) => number | null
1362
1380
  childActivityMt: (runtime: string, cwd: string) => number | null
1363
1381
  isAlive: (sock: string, identity: string) => boolean
1364
- kill: (sock: string, identity: string) => void
1382
+ /** Has THIS hosting reached its runtime's ready surface (launch's ready marker)? */
1383
+ isReady: (identity: string) => boolean
1384
+ kill: (sock: string, identity: string, reason: string) => void
1365
1385
  verbose: boolean
1366
1386
  trace: (fields: Record<string, string | number | undefined>) => void
1367
1387
  }
@@ -1374,7 +1394,7 @@ interface SuperviseCtx {
1374
1394
  * returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
1375
1395
  */
1376
1396
  function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
1377
- const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
1397
+ const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, isReady, kill, verbose, trace } = ctx
1378
1398
  // H4 — FIRST, before any reap. A launchd-managed peer is read-only: never wake,
1379
1399
  // never kill, never sweep its PROCESS. Its stale `.session` however is the
1380
1400
  // DAEMON'S own supervisory record (written back when the peer was daemon-owned),
@@ -1448,13 +1468,27 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1448
1468
  // cleared above; no legitimate alive+stopped state exists. Park like the stop verb (.idle-reaped is
1449
1469
  // idempotent with it) — .stopped keeps the peer down until `start`, .idle-reaped makes it resume-clean.
1450
1470
  if (isStopped(cfg, s.identity)) {
1451
- kill(sock, s.identity)
1471
+ kill(sock, s.identity, 'supervise-stop-enforced')
1452
1472
  setIdleReaped(cfg, s.identity)
1453
1473
  clearEphemeralArmed(cfg, s.identity)
1454
1474
  removeSessionState(cfg, s.identity)
1455
1475
  trace({ identity: s.identity, action: 'reaped-idle', reason: 'alive-but-stopped', outcome: 'operator-halt-enforced' })
1456
1476
  return { identity: s.identity, action: 'reaped-idle', reason: 'live session carried .stopped — operator halt enforced (stop-vs-wake race)' }
1457
1477
  }
1478
+ // NEVER-READY (incident 18.10): a process that never reached its runtime's ready surface well past
1479
+ // the boot + ready-gate deadlines is not a session — a codex hung before main lived 35 min under a
1480
+ // healthy supervisor. Launch now tears such a host down itself; this is the backstop for a bring-up
1481
+ // the launcher could not finish (daemon restarted mid-boot, a launcher crash). Reaped as a FAULT
1482
+ // (recordDeath → the crash-loop guard bounds a boot that keeps hanging); its ledger entries then
1483
+ // orphan and are redelivered to the next session.
1484
+ const boundMs = (cfg.bootDeadlineSecs + cfg.readyGateSecs + 60) * 1000
1485
+ if (!isReady(s.identity) && nowMs - s.wokeAt > boundMs) {
1486
+ kill(sock, s.identity, 'supervise-never-ready')
1487
+ recordDeath(cfg, s.identity, nowMs)
1488
+ removeSessionState(cfg, s.identity)
1489
+ trace({ identity: s.identity, action: 'reaped-gone', reason: 'never-ready', age: `${Math.floor((nowMs - s.wokeAt) / 1000)}s`, outcome: 'fresh-next-msg' })
1490
+ return { identity: s.identity, action: 'reaped-gone', reason: 'alive but never reached its ready surface' }
1491
+ }
1458
1492
  // Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
1459
1493
  // — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
1460
1494
  // • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
@@ -1495,7 +1529,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1495
1529
  // Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
1496
1530
  // and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
1497
1531
  if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1498
- kill(sock, s.identity)
1532
+ kill(sock, s.identity, 'supervise-ephemeral-done')
1499
1533
  clearEphemeralArmed(cfg, s.identity)
1500
1534
  removeSessionState(cfg, s.identity)
1501
1535
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
@@ -1521,7 +1555,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1521
1555
  // tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
1522
1556
  // reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
1523
1557
  if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1524
- kill(sock, s.identity)
1558
+ kill(sock, s.identity, 'supervise-ephemeral-unarmed')
1525
1559
  clearEphemeralArmed(cfg, s.identity)
1526
1560
  removeSessionState(cfg, s.identity)
1527
1561
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
@@ -1550,7 +1584,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1550
1584
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
1551
1585
  // RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
1552
1586
  // branch above) never writes it → that wakes FRESH (branch 3a).
1553
- kill(sock, s.identity)
1587
+ kill(sock, s.identity, 'supervise-idle')
1554
1588
  setIdleReaped(cfg, s.identity)
1555
1589
  removeSessionState(cfg, s.identity)
1556
1590
  trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
@@ -1567,6 +1601,8 @@ const limitHoldTraced = new Set<string>()
1567
1601
 
1568
1602
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
1569
1603
  const env = deps.env ?? process.env
1604
+ // Legacy partial cfgs get a PRIVATE namespace, not the invoking host's fleet.
1605
+ const runDir = cfg.supervisorRunDir ?? join(cfg.stateDir, 'supervisor')
1570
1606
  const nowMs = deps.nowMs ?? Date.now()
1571
1607
  const lastTurnMt =
1572
1608
  deps.lastTurnMtime ??
@@ -1574,8 +1610,12 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1574
1610
  const childActivityMt =
1575
1611
  deps.childActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).childActivityMtime?.(c) ?? null)
1576
1612
  // Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
1577
- const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
1578
- const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
1613
+ const isAlive = deps.sessionAlive ?? ((_sock: string, identity: string) => hostDaemonAlive(runDir, identity))
1614
+ // Readiness seam. A test that injects ONLY sessionAlive gets ready ≡ alive (its fixtures predate the
1615
+ // marker); production reads launch's ready marker for this very hosting.
1616
+ const isReady =
1617
+ deps.sessionReady ?? (deps.sessionAlive ? (identity: string) => deps.sessionAlive!('', identity) : (identity: string) => hostSessionReady(identity, env, runDir))
1618
+ const kill = deps.killSession ?? ((_sock: string, identity: string, reason: string) => killPtyHost(identity, env, { reason }, runDir))
1579
1619
  const verbose = superviseLogVerbose(env)
1580
1620
  // Pane-log volume ROTATION (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1581
1621
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
@@ -1613,7 +1653,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1613
1653
  })
1614
1654
  }
1615
1655
  const out: SuperviseOutcome[] = []
1616
- const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
1656
+ const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, isReady, kill, verbose, trace }
1617
1657
  for (const s of readSessionStates(cfg)) {
1618
1658
  // Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
1619
1659
  // the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
@@ -1627,6 +1667,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1627
1667
  trace({ identity: s.identity, action: 'skipped-error', reason, outcome: 'peer-error-isolated' })
1628
1668
  }
1629
1669
  }
1670
+ // NEVER-READY hosts with NO `.session` (18.10): the session state is written only AFTER a successful
1671
+ // bring-up, so a bring-up that hung and was then abandoned (the launcher died mid-boot, a daemon
1672
+ // restart) leaves a live host the loop above never sees — exactly how codex-linus, hung in dyld,
1673
+ // stayed ● for 35 min. Walk the run-dir itself. H4: never a launchd-managed peer's host.
1674
+ if (!deps.sessionAlive) {
1675
+ const known = new Set(out.map(o => o.identity))
1676
+ const boundMs = (cfg.bootDeadlineSecs + cfg.readyGateSecs + 60) * 1000
1677
+ for (const h of listHostedPeers(env, runDir)) {
1678
+ const identity = `${h.runtime}-${h.personality}`
1679
+ if (known.has(identity)) continue
1680
+ try {
1681
+ if (isLaunchdManaged(h.personality, env)) continue
1682
+ if (hostSessionReady(identity, env, runDir)) continue
1683
+ const started = hostStartedMs(identity, env, runDir)
1684
+ if (started === null || nowMs - started <= boundMs) continue
1685
+ kill('', identity, 'supervise-never-ready-unsupervised')
1686
+ recordDeath(cfg, identity, nowMs)
1687
+ trace({ identity, action: 'reaped-gone', reason: 'never-ready-unsupervised', age: `${Math.floor((nowMs - started) / 1000)}s`, outcome: 'fresh-next-msg' })
1688
+ out.push({ identity, action: 'reaped-gone', reason: 'unsupervised host never reached its ready surface' })
1689
+ } catch {
1690
+ /* per-host isolation, like the loop above */
1691
+ }
1692
+ }
1693
+ }
1630
1694
  return out
1631
1695
  }
1632
1696
 
@@ -0,0 +1,43 @@
1
+ import { frame, FRAME_DATA, FRAME_RESIZE, makeFramer, sizePayload } from './protocol.ts'
2
+ import { readGeometry, sockPath } from './paths.ts'
3
+
4
+ /** Read the running host's OWN emulator, never the shared cross-session pane-log.
5
+ * Legacy hosts already implement same-size attach: no resize or input is sent to the child. */
6
+ export async function captureHostViewport(runDir: string, session: string): Promise<string | null> {
7
+ const geometry = readGeometry(runDir, session)
8
+ if (!geometry) return null
9
+ const { cols, rows } = geometry
10
+ const bytes = await new Promise<Buffer | null>(resolve => {
11
+ let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
12
+ const chunks: Buffer[] = []
13
+ let settled = false
14
+ let quiet: ReturnType<typeof setTimeout> | undefined
15
+ const finish = () => {
16
+ if (settled) return
17
+ settled = true
18
+ clearTimeout(deadline)
19
+ clearTimeout(quiet)
20
+ conn?.end()
21
+ resolve(chunks.length ? Buffer.concat(chunks) : null)
22
+ }
23
+ const deadline = setTimeout(finish, 1500)
24
+ const consume = makeFramer((type, payload) => {
25
+ if (type !== FRAME_DATA) return
26
+ chunks.push(Buffer.from(payload))
27
+ clearTimeout(quiet)
28
+ quiet = setTimeout(finish, 80)
29
+ })
30
+ Bun.connect({ unix: sockPath(runDir, session), socket: {
31
+ open(s) { s.write(frame(FRAME_RESIZE, sizePayload(cols, rows))) },
32
+ data(_s, data) { consume(data) }, close() { finish() }, error() { finish() }, connectError() { finish() },
33
+ } }).then(s => { conn = s; if (settled) s.end() }).catch(finish)
34
+ })
35
+ if (!bytes) return null
36
+ const { Terminal } = await import('@xterm/headless')
37
+ const { modelToPlainText } = await import('./render.ts')
38
+ const term = new Terminal({ cols, rows, allowProposedApi: true })
39
+ try {
40
+ await new Promise<void>(r => term.write(bytes, r))
41
+ return modelToPlainText(term, cols, rows)
42
+ } finally { term.dispose() }
43
+ }
@@ -5,7 +5,10 @@
5
5
  // serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
6
6
  // binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
7
7
  import { spawn as nodeSpawn } from 'node:child_process'
8
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { formatEventLine } from '../storage/rotatelog.ts'
9
12
  import { fileURLToPath } from 'node:url'
10
13
  import { runSupervisorClient, sessionAlive } from './client.ts'
11
14
  import { defaultRunDir, logPath, ownershipVerdict, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
@@ -217,7 +220,30 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
217
220
  })
218
221
  }
219
222
 
220
- export function killSession(runDir: string, session: string): boolean {
223
+ export interface TerminationContext { reason?: string; caller?: string; sandbox?: boolean; targetPid?: number; targetToken?: string }
224
+
225
+ /** Strict write-ahead audit: if attribution cannot be persisted, do not signal. No payload/env. */
226
+ export function auditTermination(runDir: string, targets: string[], context: TerminationContext = {}): void {
227
+ const canonical = (p: string) => { try { return realpathSync(p) } catch { return resolve(p) } }
228
+ if ((context.sandbox || process.env.IAPEER_TEST_SANDBOX === '1') && canonical(runDir) === canonical(join(homedir(), '.iapeer/state/supervisor'))) {
229
+ throw new Error('refusing to signal the REAL supervisor run-dir under IAPEER_TEST_SANDBOX=1')
230
+ }
231
+ mkdirSync(runDir, { recursive: true, mode: 0o700 })
232
+ const fd = openSync(join(runDir, 'terminations.log'), 'a', 0o600)
233
+ try {
234
+ appendFileSync(fd, formatEventLine(Date.now(), {
235
+ ev: targets.length > 1 ? 'termination-batch' : 'termination-request',
236
+ targets: targets.join(','), caller: context.caller ?? process.env.PEER_IDENTITY ??
237
+ (process.env.PEER_PERSONALITY ? `${process.env.PEER_RUNTIME ?? 'unknown'}-${process.env.PEER_PERSONALITY}` : `uid:${process.getuid?.() ?? 'unknown'}`),
238
+ caller_pid: process.pid, caller_ppid: process.ppid,
239
+ target_pid: context.targetPid, target_token: context.targetToken,
240
+ reason: context.reason ?? `supervisor-kill:${process.argv[2] ?? 'api'}`,
241
+ }) + '\n')
242
+ fsyncSync(fd)
243
+ } finally { closeSync(fd) }
244
+ }
245
+
246
+ export function killSession(runDir: string, session: string, context: TerminationContext = {}): boolean {
221
247
  const rec = readPidFile(runDir, session)
222
248
  if (!rec) return false
223
249
  // В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
@@ -228,6 +254,7 @@ export function killSession(runDir: string, session: string): boolean {
228
254
  if (rec.token && !ownershipVerdict(pidStartToken(rec.pid), rec.token)) {
229
255
  return false // reused pid — NOT our session; do not signal it
230
256
  }
257
+ auditTermination(runDir, [session], { ...context, targetPid: rec.pid, targetToken: rec.token })
231
258
  try {
232
259
  process.kill(rec.pid, 'SIGTERM')
233
260
  } catch {
@@ -53,6 +53,7 @@ import {
53
53
  hostGeometry,
54
54
  hostRunDir,
55
55
  hostSessionAlive,
56
+ hostSessionReady,
56
57
  hostSessionToken,
57
58
  listHostedPeers,
58
59
  resubmitHosted,
@@ -103,22 +104,29 @@ function monotonicMs(): number {
103
104
  }
104
105
 
105
106
  function sessionAlive(_sock: string, address: string, env: NodeJS.ProcessEnv = process.env): boolean {
106
- // pty-only: a peer's liveness is its supervisor daemon (a cheap pid-file check). `address` IS the
107
- // identity. (`_sock` is retained for call-site compatibility; tmux sockets no longer exist.)
108
- // env sandboxes the run-dir — an env-injected verb must not read the real fleet's liveness.
109
- return hostSessionAlive(address, env)
107
+ // DELIVERY liveness = the hosting is alive AND its runtime reached its ready surface (incident
108
+ // 18.10: a codex hung before main lived under a healthy supervisor and took a message with a false
109
+ // ok). A not-yet-ready session routes as a MISS → the wake path, which waits on the in-flight
110
+ // bring-up (wake.lock) or replaces a failed one. `address` IS the identity; `_sock` is legacy.
111
+ return hostSessionReady(address, env)
110
112
  }
111
113
 
112
- /** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
113
- export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): boolean {
114
- const socketPath = buildSocketPath(runtime, personality, sockDir)
115
- return sessionAlive(socketPath, buildProcessAddress(runtime, personality), env)
114
+ /** Is the (runtime,personality) endpoint's PROCESS up right now (ready or not)? The operator-verb
115
+ * guard (stop/remove/rename must still reach a hung process) — NOT a delivery predicate. */
116
+ export function isPeerLive(runtime: string, personality: string, _sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): boolean {
117
+ return hostSessionAlive(buildProcessAddress(runtime, personality), env)
118
+ }
119
+
120
+ /** Is the endpoint up AND its runtime ready (accepts input)? — what `list` shows as ●. */
121
+ export function isPeerReady(runtime: string, personality: string, env: NodeJS.ProcessEnv = process.env): boolean {
122
+ return hostSessionReady(buildProcessAddress(runtime, personality), env)
116
123
  }
117
124
 
118
125
  export function listOnlinePeers(_sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): OnlinePeer[] {
119
126
  // pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
120
127
  const out = new Map<string, OnlinePeer>()
121
128
  for (const h of listHostedPeers(env)) {
129
+ // Operator discovery includes booting/hung processes so interrupt/stop can still address them.
122
130
  out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
123
131
  }
124
132
  return Array.from(out.values()).sort(
@@ -132,6 +140,8 @@ export function resolveDeliveryTarget(args: {
132
140
  sockDir?: string
133
141
  /** Б7 — injected env sandboxes the liveness reads (supervisor run-dir); default process.env (prod). */
134
142
  env?: NodeJS.ProcessEnv
143
+ /** Operator control can address a hung/booting process; delivery cannot. */
144
+ processOnly?: boolean
135
145
  }): Result<DeliveryTarget> {
136
146
  const env = args.env ?? process.env
137
147
  const sockDir = args.sockDir ?? resolveSockDir(env)
@@ -143,12 +153,13 @@ export function resolveDeliveryTarget(args: {
143
153
  const runtime = args.runtime
144
154
  const socketPath = buildSocketPath(runtime, args.personality, sockDir)
145
155
  const address = buildProcessAddress(runtime, args.personality)
146
- if (!sessionAlive(socketPath, address, env)) {
156
+ if (!(args.processOnly ? hostSessionAlive(address, env) : sessionAlive(socketPath, address, env))) {
147
157
  return err(`peer offline: ${args.personality} (${args.runtime})`)
148
158
  }
149
159
  return ok({ runtime, personality: args.personality, address, socketPath })
150
160
  }
151
- const matches = listOnlinePeers(sockDir, env).filter(peer => peer.personality === args.personality)
161
+ const matches = listOnlinePeers(sockDir, env).filter(peer => peer.personality === args.personality &&
162
+ (args.processOnly || hostSessionReady(buildProcessAddress(peer.runtime, peer.personality), env)))
152
163
  if (matches.length === 0) return err(`peer offline: ${args.personality}`)
153
164
  if (matches.length > 1) {
154
165
  return err(
@@ -1220,13 +1231,13 @@ export interface ControlResult {
1220
1231
  * live endpoints stays ambiguous → explicit refusal. An explicit runtime is exact.
1221
1232
  */
1222
1233
  export function resolveControlTarget(personality: string, runtime: string | undefined, env: NodeJS.ProcessEnv = process.env): Result<DeliveryTarget> {
1223
- if (runtime) return resolveDeliveryTarget({ personality, runtime, env })
1234
+ if (runtime) return resolveDeliveryTarget({ personality, runtime, env, processOnly: true })
1224
1235
  const peer = findPeer(readPeersIndex({ env }), personality)
1225
1236
  if (peer) {
1226
- const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env })
1237
+ const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env, processOnly: true })
1227
1238
  if (anchored.ok) return anchored
1228
1239
  }
1229
- return resolveDeliveryTarget({ personality, env })
1240
+ return resolveDeliveryTarget({ personality, env, processOnly: true })
1230
1241
  }
1231
1242
 
1232
1243
  export async function routeControl(personality: string, runtime: string | undefined, command: ControlCommand): Promise<Result<ControlResult>> {
@@ -12,7 +12,7 @@
12
12
  // peer row hands off to `iapeer attach`, the terminal-handoff the API deliberately
13
13
  // keeps client-side).
14
14
 
15
- const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
15
+ const GLYPH: Record<'live' | 'booting' | 'asleep' | 'stopped', string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
16
16
 
17
17
  const COLOR = {
18
18
  meta: '#8b949e',
@@ -31,7 +31,7 @@ const APPROVAL_CONTENT_MAX_CHARS = 8000
31
31
 
32
32
  export interface TrayRuntime {
33
33
  runtime: string
34
- status: 'live' | 'asleep' | 'stopped'
34
+ status: 'live' | 'booting' | 'asleep' | 'stopped'
35
35
  attached?: boolean
36
36
  }
37
37
 
@@ -134,13 +134,14 @@ export function fmtClock(now: number): string {
134
134
 
135
135
  // ── peer status aggregation ─────────────────────────────────────────────────────
136
136
 
137
- function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
137
+ function aggregate(p: TrayPeer): 'live' | 'booting' | 'asleep' | 'stopped' {
138
138
  if (p.runtimes.some(r => r.status === 'live')) return 'live'
139
+ if (p.runtimes.some(r => r.status === 'booting')) return 'booting'
139
140
  if (p.runtimes.some(r => r.status === 'asleep')) return 'asleep'
140
141
  return 'stopped'
141
142
  }
142
143
 
143
- const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
144
+ const RANK: Record<'live' | 'booting' | 'asleep' | 'stopped', number> = { live: 0, booting: 1, asleep: 2, stopped: 3 }
144
145
 
145
146
  // 🔒 launchd-managed peers (the human channel + service infra — arthur/timer/watcher) are
146
147
  // NON-clickable status rows. They form ONE contiguous block at the very TOP, before the first
@@ -23,8 +23,8 @@ const POLL_MS = 2000
23
23
  const HOST_POLL_MS = 10_000
24
24
  const LOG_LIMIT = 12
25
25
 
26
- const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
27
- const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', asleep: undefined, stopped: 'red' }
26
+ const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
27
+ const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', booting: 'yellow', asleep: undefined, stopped: 'red' }
28
28
 
29
29
  function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
30
30
  return (
@@ -226,9 +226,12 @@ async function waitJobGone(run: SysRunner, uid: number, label: string, timeoutMs
226
226
  * wait for its pidfile-liveness to drop). Best-effort per session; reports laggards. */
227
227
  async function stopHostedSessions(env: NodeJS.ProcessEnv, timeoutMs = 8000): Promise<{ stopped: string[]; stuck: string[] }> {
228
228
  const identities = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
229
+ const { auditTermination } = await import('../supervisor/index.ts')
230
+ const { hostRunDir } = await import('../launch/ptyHost.ts')
231
+ if (identities.length) auditTermination(hostRunDir(env), identities, { reason: 'uninstall-hosted-sessions', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
229
232
  for (const id of identities) {
230
233
  try {
231
- killPtyHost(id, env)
234
+ killPtyHost(id, env, { reason: 'uninstall-hosted-sessions' })
232
235
  } catch {
233
236
  /* verified-dead / permission — the wait below reports it */
234
237
  }
@@ -258,6 +261,10 @@ export async function executeUninstall(opts: UninstallOptions = {}): Promise<Uni
258
261
  }
259
262
 
260
263
  const uid = process.getuid?.() ?? 0
264
+ const { auditTermination } = await import('../supervisor/index.ts')
265
+ const { hostRunDir } = await import('../launch/ptyHost.ts')
266
+ const targets = plan.items.filter(i => i.present && i.action === 'bootout-remove-plist').map(i => i.path ?? i.what)
267
+ if (targets.length) auditTermination(hostRunDir(env), targets, { reason: 'uninstall', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
261
268
  // Set when a launchd job survived bootout (В56) — ~/.iapeer must NOT be removed
262
269
  // from under a job launchd may respawn (KeepAlive) into the deleted tree.
263
270
  let jobStillLoaded: string | undefined
@@ -39,6 +39,8 @@ import { IAPEER_VERSION } from '../core/version.ts'
39
39
  import { iapeerBinPath, stampBinaryHealthy } from '../install/index.ts'
40
40
  import { ensureExecutableSignature } from '../install/signature.ts'
41
41
  import { readPeersIndex } from '../registry/index.ts'
42
+ import { auditTermination } from '../supervisor/index.ts'
43
+ import { defaultRunDir } from '../supervisor/paths.ts'
42
44
  import {
43
45
  cycleDaemon,
44
46
  cycleLaunchdJob,
@@ -263,6 +265,9 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
263
265
  if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return []
264
266
  const out: InfraJobRecycleResult[] = []
265
267
  const index = readPeersIndex({ env })
268
+ const targets = index.peers.flatMap(peer => [...new Set([peer.runtime, ...peer.runtimes])].filter(isInfraRuntime)
269
+ .map(runtime => resolveAlwaysOnTarget(peer.personality, runtime, env)).filter(t => isFoundationOwnedPlist(t.path)).map(t => t.label))
270
+ if (targets.length) auditTermination(defaultRunDir(env), targets, { reason: 'update-recycle-infra', caller: env.PEER_IDENTITY })
266
271
  for (const peer of index.peers) {
267
272
  // Multi-infra (0.4.88): a personality can hold SEVERAL always-on channels
268
273
  // (e.g. arthur: telegram on the legacy base plist + web on the per-runtime