@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.
@@ -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