@agfpd/iapeer 0.4.133 → 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
@@ -77,7 +77,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
77
77
  |---|---|
78
78
  | `enable <plugin> [peer] [--no-setup]` | Install and enable a capability plugin for a peer (across runtimes), run its setup step. |
79
79
  | `native-memory <off\|on> (--peer <p> \| --all)` | Turn off/restore a peer's runtimes' native memory. |
80
- | `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. |
80
+ | `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. The core also re-asserts the trust of `<cwd>/.codex/hooks.json` on EVERY codex launch (idempotent), and `rename` / `approval-mode yolo` restore what must stay trusted — a codex peer boots to its input with no human even after its trust was wiped (18.10). |
81
81
 
82
82
  ## Agent self-calls
83
83
 
@@ -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.133",
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
+ }
@@ -161,6 +161,10 @@ export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = proces
161
161
  return path
162
162
  }
163
163
  writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
164
+ // The trust wipe above is cwd-wide (codex keys state POSITIONALLY, so removing our group shifts the
165
+ // others' keys). Re-seed whatever hooks REMAIN — the memory provider's among them — or the next codex
166
+ // boot stops at «hooks need review» (incident 18.10).
167
+ preSeedCodexHooksTrust(path, env)
164
168
  return path
165
169
  }
166
170
 
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()}` : ''})`
@@ -1439,9 +1448,13 @@ export async function renamePeerCli(
1439
1448
  const { preTrustCodexCwd, removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
1440
1449
  preTrustCodexCwd(newCwd, env)
1441
1450
  removeCodexCwdTrust(oldCwd, env)
1442
- const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
1451
+ const { removeCodexHooksTrustUnder, preSeedCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
1443
1452
  removeCodexHooksTrustUnder(oldCwd, env)
1444
- sideEffects.push('codex-trust: re-trusted new cwd + cleaned old')
1453
+ // Hooks trust is keyed by the hooks.json PATH, so it must move with the cwd — without this a
1454
+ // rename round-trip (a→b→a) wiped it entirely and codex stopped at «hooks need review» (18.10).
1455
+ const newHooks = join(newCwd, '.codex', 'hooks.json')
1456
+ if (existsSync(newHooks)) preSeedCodexHooksTrust(newHooks, env)
1457
+ sideEffects.push('codex-trust: re-trusted new cwd (+ hooks) + cleaned old')
1445
1458
  } catch (e) {
1446
1459
  sideEffects.push(`codex-trust: failed (${e instanceof Error ? e.message : String(e)})`)
1447
1460
  }
@@ -2427,6 +2440,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2427
2440
  ? [positionals[0]]
2428
2441
  : null
2429
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' })
2430
2444
  const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
2431
2445
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
2432
2446
  // В48 — a REAL bootout error (reason set; benign already-unloaded carries none) is exit 1:
@@ -3018,7 +3032,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
3018
3032
  }
3019
3033
  out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
3020
3034
  const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
3021
- killSession(sock, identity, env)
3035
+ killSession(sock, identity, env, 'self-fresh')
3022
3036
  return 0
3023
3037
  }
3024
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,
@@ -41,10 +41,11 @@
41
41
 
42
42
  import { homedir } from 'os'
43
43
  import { join } from 'path'
44
- import { readdirSync, realpathSync, statSync } from 'fs'
44
+ import { existsSync, readdirSync, realpathSync, statSync } from 'fs'
45
+ import { preSeedCodexHooksTrust } from '../codexHooksTrust.ts'
45
46
  import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
46
47
  import { detectNumberedModal } from './modalDetect.ts'
47
- import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
48
+ import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, PreparedLaunch, RuntimeAdapter } from '../types.ts'
48
49
 
49
50
  /**
50
51
  * The `/goal` subcommands we will type into a live codex session (see executeControl → 'goal').
@@ -170,6 +171,27 @@ export const codexAdapter: RuntimeAdapter = {
170
171
  kind: 'tui',
171
172
  usesDoctrine: true,
172
173
 
174
+ /**
175
+ * Hooks trust, re-asserted on EVERY launch (incident 18.10: oc-test-nemo/codex stopped at «PostToolUse
176
+ * hooks — 1 hook needs review» after a rename round-trip had wiped its `[hooks.state]` — a fresh codex
177
+ * that meets an untrusted hook waits for a human forever). The invariant «a codex peer boots to its
178
+ * input with no human» therefore lives HERE, not only at provision time: whatever erased the trust
179
+ * (rename, an approval-mode flip, a hand edit of config.toml), the next launch restores it. The peer's
180
+ * `.codex/hooks.json` is operator/provider-owned configuration, trusted like its `.mcp.json`.
181
+ * Idempotent (no write when the hashes already match); a failure is a launch WARNING, never a block —
182
+ * the modal is still better than no session, and the warning names it.
183
+ */
184
+ prepareLaunch(spec, _baseEnv, cfg): PreparedLaunch {
185
+ const env = cfg.env ?? process.env
186
+ const hooks = join(spec.cwd, '.codex', 'hooks.json')
187
+ const warnings: string[] = []
188
+ if (existsSync(hooks)) {
189
+ const t = preSeedCodexHooksTrust(hooks, env)
190
+ if (t.state === 'failed') warnings.push(`codex hooks trust not seeded (${t.detail ?? 'unknown'}) — a review modal may block the boot`)
191
+ }
192
+ return { env: {}, ...(warnings.length ? { warnings } : {}) }
193
+ },
194
+
173
195
  /**
174
196
  * Delivery markers for submitIntoTui (docs/Рантайм codex "Доставка"):
175
197
  * - promptGlyphs ['›'] — codex's input-row arrow (the same stable invariant as
@@ -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