@agfpd/iapeer 0.4.33 → 0.4.34

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.33",
3
+ "version": "0.4.34",
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",
package/src/cli/index.ts CHANGED
@@ -129,7 +129,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
129
129
  return index.peers.map(peer => {
130
130
  const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
131
131
  runtime: rt,
132
- status: isPeerLive(rt, peer.personality, cfg.sockDir)
132
+ status: isPeerLive(rt, peer.personality, cfg.sockDir, env)
133
133
  ? 'live'
134
134
  : isStopped(cfg, buildProcessAddress(rt, peer.personality))
135
135
  ? 'stopped'
@@ -248,7 +248,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
248
248
  // the service was already not loaded — benign for stop — but surface the detail in
249
249
  // the reason rather than silently claiming success.)
250
250
  const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
251
- killSession(sock, identity)
251
+ killSession(sock, identity, env)
252
252
  out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
253
253
  } else {
254
254
  // A deliberate stop is a CLEAN PARK, not a death (stop→start must survive ≥
@@ -258,7 +258,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
258
258
  // no reaped-gone death class for a state the daemon knows 100%).
259
259
  setStopped(cfg, identity)
260
260
  setIdleReaped(cfg, identity)
261
- killSession(sock, identity)
261
+ killSession(sock, identity, env)
262
262
  removeSessionState(cfg, identity)
263
263
  out.push({ personality, runtime: rt, action: 'stopped' })
264
264
  }
@@ -551,7 +551,7 @@ export async function newPeer(
551
551
  clearStopped(cfg, identity)
552
552
  clearIdleReaped(cfg, identity)
553
553
  clearNewEager(cfg, identity)
554
- killSession(sock, identity) // canary-clean deliberate teardown; no-op when dead
554
+ killSession(sock, identity, env) // canary-clean deliberate teardown; no-op when dead
555
555
  removeSessionState(cfg, identity) // never a death class — supervise stays silent
556
556
  const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
557
557
  const r = await wake({ personality, runtime: rt, task: '', resume: false })
@@ -626,7 +626,7 @@ export async function compactPeer(
626
626
  if (!done.ok) return { personality, runtime: rt, action: 'failed', woke, reason: done.error.message }
627
627
  return null
628
628
  }
629
- const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
629
+ const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir, env))
630
630
  if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
631
631
  const rt = (runtime as Runtime | undefined)
632
632
  ?? (liveRuntimes.includes(peer.runtime) ? peer.runtime : liveRuntimes.length === 1 ? liveRuntimes[0] : undefined)
@@ -744,7 +744,7 @@ export async function removePeerCli(
744
744
  if (!peer) return { personality, action: 'absent' }
745
745
  if (!opts.force) {
746
746
  const cfg = loadLifecycleConfig(env)
747
- const liveRt = peer.runtimes.find(rt => isPeerLive(rt, personality, cfg.sockDir))
747
+ const liveRt = peer.runtimes.find(rt => isPeerLive(rt, personality, cfg.sockDir, env))
748
748
  if (liveRt) {
749
749
  return {
750
750
  personality,
@@ -969,7 +969,7 @@ export async function renamePeerCli(
969
969
  }
970
970
  const cfg = loadLifecycleConfig(env)
971
971
  if (!opts.force) {
972
- const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
972
+ const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir, env))
973
973
  if (liveRt) {
974
974
  return {
975
975
  oldPersonality,
@@ -2325,7 +2325,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2325
2325
  setNewEager(cfg, identity)
2326
2326
  out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
2327
2327
  const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
2328
- killSession(sock, identity)
2328
+ killSession(sock, identity, env)
2329
2329
  return 0
2330
2330
  }
2331
2331
  case 'interrupt': {
@@ -183,6 +183,7 @@ export const launch: LaunchFn = async (
183
183
  paneLogPath,
184
184
  exitLogPath: cfg.exitLogDir ? exitLogPath(cfg.exitLogDir) : undefined,
185
185
  invocation,
186
+ env, // sandbox the supervisor run-dir (serve-spec + detached spawn) via the injected env
186
187
  })
187
188
  if (host.ok) {
188
189
  // ROUTER (telegram/notifier): no startup dialogs and no transcript. The session is "up" the
@@ -193,7 +194,7 @@ export const launch: LaunchFn = async (
193
194
  // router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
194
195
  // no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
195
196
  if (adapter.kind === 'router') {
196
- if (hostSessionAlive(identity)) return ready(identity)
197
+ if (hostSessionAlive(identity, env)) return ready(identity)
197
198
  return fail(identity, 'hosted router session not alive immediately after bringup')
198
199
  }
199
200
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
@@ -201,7 +202,7 @@ export const launch: LaunchFn = async (
201
202
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
202
203
  const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
203
204
  const isReady = await waitHostReady(
204
- { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte },
205
+ { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, env },
205
206
  sleep,
206
207
  )
207
208
  if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
@@ -220,12 +221,12 @@ export const launch: LaunchFn = async (
220
221
  },
221
222
  { env },
222
223
  )
223
- const d = await deliverHosted(identity, firstMessage)
224
+ const d = await deliverHosted(identity, firstMessage, env)
224
225
  if (!d.ok) return fail(identity, `hosted deliver failed: ${d.error}`)
225
226
  const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
226
227
  while (Date.now() < readyDeadline) {
227
228
  await sleep(2000)
228
- if (!hostSessionAlive(identity)) return fail(identity, 'hosted session vanished during ready-gate')
229
+ if (!hostSessionAlive(identity, env)) return fail(identity, 'hosted session vanished during ready-gate')
229
230
  if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
230
231
  }
231
232
  return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
@@ -87,7 +87,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
87
87
  // pty-only liveness: a router is supervisor-HOSTED. Probe by RUNTIME STATE (not the `.pty-host`
88
88
  // marker). On child death it goes false → block-watch exits → run-infra exits → launchd KeepAlive
89
89
  // respawns (the SOLE respawn owner; the supervisor never self-respawns — H4 held).
90
- const isAlive = (): boolean => hostSessionAlive(identity)
90
+ const isAlive = (): boolean => hostSessionAlive(identity, env)
91
91
 
92
92
  const cfg: LaunchConfig = {
93
93
  claudeBin: env.CLAUDE_BIN ?? 'claude',
@@ -164,7 +164,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
164
164
  // kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans up sock/pid/serve)
165
165
  // so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
166
166
  // (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
167
- if (stop) killPtyHost(identity)
167
+ if (stop) killPtyHost(identity, env)
168
168
  return 0
169
169
  }
170
170
 
@@ -27,9 +27,11 @@ export const HOST_COLS = 220
27
27
  export const HOST_ROWS = 50
28
28
 
29
29
 
30
- /** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
31
- export function hostRunDir(): string {
32
- return defaultRunDir()
30
+ /** The supervisor's run dir (session sockets/pids/serve-specs). Resolved from the INJECTED env so a
31
+ * sandboxed caller (cfg.env) never touches the real ~/.iapeer/state/supervisor — the isolation
32
+ * invariant. Defaults to process.env for the prod daemon (env === process.env). */
33
+ export function hostRunDir(env: NodeJS.ProcessEnv = process.env): string {
34
+ return defaultRunDir(env)
33
35
  }
34
36
 
35
37
  /**
@@ -39,13 +41,14 @@ export function hostRunDir(): string {
39
41
  * means rolling the marker back (rm) on a STILL-RUNNING hosted session never tears it (rm affects only
40
42
  * the NEXT spawn). Rollback of a RUNNING hosted session is therefore kill-then-rm, NOT rm-alone.
41
43
  */
42
- export function hostSessionAlive(identity: string): boolean {
43
- return hostDaemonAlive(hostRunDir(), identity)
44
+ export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
45
+ return hostDaemonAlive(hostRunDir(env), identity)
44
46
  }
45
47
 
46
- /** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up). */
47
- export function killPtyHost(identity: string): void {
48
- killSupervisorSession(hostRunDir(), identity)
48
+ /** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up).
49
+ * 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)
49
52
  }
50
53
 
51
54
  /**
@@ -56,8 +59,8 @@ export function killPtyHost(identity: string): void {
56
59
  * a human's unfinished composer input, but NEVER for the AI's own composer output when no one is
57
60
  * attached. The host equivalent of `hasAttachedTmuxClient`.
58
61
  */
59
- export function hasAttachedSupervisorClient(identity: string): boolean {
60
- return existsSync(attachedPath(hostRunDir(), identity))
62
+ export function hasAttachedSupervisorClient(identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
63
+ return existsSync(attachedPath(hostRunDir(env), identity))
61
64
  }
62
65
 
63
66
  /** Current child pty geometry of a hosted session, from the supervisor's `<session>.geometry.json`
@@ -65,8 +68,8 @@ export function hasAttachedSupervisorClient(identity: string): boolean {
65
68
  * rather than assuming the fixed HOST geometry, because a DETACHED session keeps its last client geometry
66
69
  * (the child is no longer reverted to serve on detach — that revert caused the reattach-duplicate bug).
67
70
  * Reading the actual geometry keeps the pane-log composer-occupancy model aligned with the real pty size. */
68
- export function hostGeometry(identity: string): { cols: number; rows: number } {
69
- return readGeometry(hostRunDir(), identity) ?? { cols: HOST_COLS, rows: HOST_ROWS }
71
+ export function hostGeometry(identity: string, env: NodeJS.ProcessEnv = process.env): { cols: number; rows: number } {
72
+ return readGeometry(hostRunDir(env), identity) ?? { cols: HOST_COLS, rows: HOST_ROWS }
70
73
  }
71
74
 
72
75
  /**
@@ -75,9 +78,9 @@ export function hostGeometry(identity: string): { cols: number; rows: number } {
75
78
  * session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
76
79
  * `session_id:pane_id` token. null when no daemon pid is readable.
77
80
  */
78
- export function hostSessionToken(identity: string): string | null {
81
+ export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env): string | null {
79
82
  try {
80
- const pid = readFileSync(pidPath(hostRunDir(), identity), 'utf8').trim()
83
+ const pid = readFileSync(pidPath(hostRunDir(env), identity), 'utf8').trim()
81
84
  return pid ? `host:${pid}` : null
82
85
  } catch {
83
86
  return null
@@ -90,9 +93,9 @@ export function hostSessionToken(identity: string): string | null {
90
93
  * scan (and the no-runtime delivery resolve) include hosted peers. Empty + cheap when flag-off (the
91
94
  * run-dir is empty).
92
95
  */
93
- export function listHostedPeers(): Array<{ runtime: TmuxRuntime; personality: string }> {
96
+ export function listHostedPeers(env: NodeJS.ProcessEnv = process.env): Array<{ runtime: TmuxRuntime; personality: string }> {
94
97
  const out: Array<{ runtime: TmuxRuntime; personality: string }> = []
95
- for (const s of listSupervisorSessions(hostRunDir())) {
98
+ for (const s of listSupervisorSessions(hostRunDir(env))) {
96
99
  if (!s.alive) continue
97
100
  const p = parseSessionName(s.session)
98
101
  if (p) out.push({ runtime: p.runtime, personality: p.personality })
@@ -118,13 +121,16 @@ export async function startPtyHost(b: {
118
121
  paneLogPath: string
119
122
  exitLogPath?: string
120
123
  invocation: LaunchInvocation
124
+ /** Injected env — the run-dir (serve-spec + detached spawn) resolves from THIS, so a sandboxed
125
+ * launch never writes serve secrets to / spawns a process against the real ~/.iapeer. */
126
+ env?: NodeJS.ProcessEnv
121
127
  }): Promise<HostStartResult> {
122
128
  try {
123
129
  const { startSupervisorDaemon } = await import('../supervisor/index.ts')
124
130
  const r = await startSupervisorDaemon({
125
131
  session: b.identity,
126
132
  runtime: b.runtime,
127
- runDir: hostRunDir(),
133
+ runDir: hostRunDir(b.env),
128
134
  serve: {
129
135
  argv: b.invocation.argv,
130
136
  env: b.invocation.env as Record<string, string>,
@@ -148,7 +154,7 @@ export async function startPtyHost(b: {
148
154
  * 2 s cadence + bootDeadline.
149
155
  */
150
156
  export async function waitHostReady(
151
- b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number },
157
+ b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; env?: NodeJS.ProcessEnv },
152
158
  sleep: (ms: number) => Promise<void>,
153
159
  ): Promise<boolean> {
154
160
  const log = `${b.logDir}/${b.identity}.log`
@@ -160,7 +166,7 @@ export async function waitHostReady(
160
166
  // session (the first message is then lost). See readyGateModel.loadPaneLogModel.
161
167
  for (let i = 0; i < iters; i++) {
162
168
  await sleep(2000)
163
- if (!hostSessionAlive(b.identity)) return false
169
+ if (!hostSessionAlive(b.identity, b.env)) return false
164
170
  const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
165
171
  if (view && b.adapter.isInputReady(view)) return true
166
172
  }
@@ -168,6 +174,6 @@ export async function waitHostReady(
168
174
  }
169
175
 
170
176
  /** Deliver the first/next message to the hosted session over its supervisor socket. */
171
- export function deliverHosted(identity: string, message: string): Promise<DeliverResult> {
172
- return deliverToHost(hostRunDir(), identity, message)
177
+ export function deliverHosted(identity: string, message: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
178
+ return deliverToHost(hostRunDir(env), identity, message)
173
179
  }
@@ -715,11 +715,12 @@ export async function withWakeLock<T>(
715
715
  // liveness
716
716
  // ─────────────────────────────────────────────────────────────────────────────
717
717
 
718
- function sessionAlive(_sock: string, identity: string): boolean {
718
+ function sessionAlive(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
719
719
  // pty-only: a peer is alive iff its supervisor session is live (a cheap pid-file check). Routing
720
720
  // liveness on the live session (not the `.pty-host` spawn-intent marker) keeps a marker rollback safe
721
721
  // on a still-running session. (`_sock` retained for call-site compatibility; no tmux sockets exist.)
722
- return hostSessionAlive(identity)
722
+ // env sandboxes the run-dir — a test with an injected root must not read the real fleet's liveness.
723
+ return hostSessionAlive(identity, env)
723
724
  }
724
725
 
725
726
  /** Death-class tag for a gone session (pty-only). A gone peer is a supervisor session that ended; its
@@ -969,7 +970,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
969
970
  }
970
971
  // Idempotent fast path inside the lock: a live session wins (a concurrent
971
972
  // wake already brought it up) — no second spawn.
972
- if (sessionAlive(sock, identity)) {
973
+ if (sessionAlive(sock, identity, env)) {
973
974
  writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
974
975
  // taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
975
976
  // the boot first-message; THIS caller's task was not delivered by anything.
@@ -1069,7 +1070,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1069
1070
  // path above). We do NOT flip to READY — the wake DID fail its ready-gate, the caller
1070
1071
  // must still hear FAILED; we only make the live remnant supervised. Best-effort: a write
1071
1072
  // hiccup must not mask the original FAILED reason.
1072
- if (sessionAlive(sock, identity)) {
1073
+ if (sessionAlive(sock, identity, env)) {
1073
1074
  try {
1074
1075
  writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
1075
1076
  } catch { /* keep the original FAILED reason — enroll is best-effort */ }
@@ -1108,11 +1109,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1108
1109
  // Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
1109
1110
  // ─────────────────────────────────────────────────────────────────────────────
1110
1111
 
1111
- export function killSession(_sock: string, identity: string): void {
1112
+ export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
1112
1113
  // pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
1113
1114
  // sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
1114
- // call-site compatibility; no tmux sockets exist.)
1115
- killPtyHost(identity)
1115
+ // call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
1116
+ // injected root must NEVER SIGTERM a real fleet peer's supervisor.
1117
+ killPtyHost(identity, env)
1116
1118
  }
1117
1119
 
1118
1120
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1150,6 +1152,9 @@ export interface SuperviseDeps {
1150
1152
  /** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
1151
1153
  * verdict so the idle path is exercised hermetically — no live pty session needed. */
1152
1154
  sessionAlive?: (sock: string, identity: string) => boolean
1155
+ /** Reap seam (default: the real `killSession` = SIGTERM the supervisor daemon). Tests inject a no-op
1156
+ * so exercising the reap path NEVER SIGTERMs a real process — the destructive-kill isolation guard. */
1157
+ killSession?: (sock: string, identity: string) => void
1153
1158
  }
1154
1159
 
1155
1160
  /** Resolved per-tick context threaded into superviseOnePeer (deps already defaulted by superviseTick). */
@@ -1159,6 +1164,7 @@ interface SuperviseCtx {
1159
1164
  nowMs: number
1160
1165
  lastTurnMt: (runtime: string, cwd: string) => number | null
1161
1166
  isAlive: (sock: string, identity: string) => boolean
1167
+ kill: (sock: string, identity: string) => void
1162
1168
  verbose: boolean
1163
1169
  trace: (fields: Record<string, string | number | undefined>) => void
1164
1170
  }
@@ -1171,7 +1177,7 @@ interface SuperviseCtx {
1171
1177
  * returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
1172
1178
  */
1173
1179
  function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
1174
- const { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace } = ctx
1180
+ const { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace } = ctx
1175
1181
  // H4 — FIRST, before any reap. A launchd-managed peer is read-only.
1176
1182
  if (isLaunchdManaged(s.personality, env)) {
1177
1183
  if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
@@ -1252,7 +1258,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1252
1258
  // Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
1253
1259
  // and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
1254
1260
  if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1255
- killSession(sock, s.identity)
1261
+ kill(sock, s.identity)
1256
1262
  clearEphemeralArmed(cfg, s.identity)
1257
1263
  removeSessionState(cfg, s.identity)
1258
1264
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
@@ -1278,7 +1284,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1278
1284
  // tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
1279
1285
  // reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
1280
1286
  if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1281
- killSession(sock, s.identity)
1287
+ kill(sock, s.identity)
1282
1288
  clearEphemeralArmed(cfg, s.identity)
1283
1289
  removeSessionState(cfg, s.identity)
1284
1290
  trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
@@ -1295,7 +1301,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1295
1301
  // INITIATES. Its presence on the next wake = the session was parked cleanly =
1296
1302
  // RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
1297
1303
  // branch above) never writes it → that wakes FRESH (branch 3a).
1298
- killSession(sock, s.identity)
1304
+ kill(sock, s.identity)
1299
1305
  setIdleReaped(cfg, s.identity)
1300
1306
  removeSessionState(cfg, s.identity)
1301
1307
  trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
@@ -1311,7 +1317,9 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1311
1317
  const env = deps.env ?? process.env
1312
1318
  const nowMs = deps.nowMs ?? Date.now()
1313
1319
  const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
1314
- const isAlive = deps.sessionAlive ?? sessionAlive
1320
+ // Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
1321
+ const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
1322
+ const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
1315
1323
  const verbose = superviseLogVerbose(env)
1316
1324
  // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1317
1325
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
@@ -1328,7 +1336,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1328
1336
  const trace = (fields: Record<string, string | number | undefined>): void =>
1329
1337
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
1330
1338
  const out: SuperviseOutcome[] = []
1331
- const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace }
1339
+ const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace }
1332
1340
  for (const s of readSessionStates(cfg)) {
1333
1341
  // Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
1334
1342
  // the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
@@ -1434,7 +1442,7 @@ export async function drainEphemeralQueue(
1434
1442
  const env = deps.env ?? process.env
1435
1443
  const identity = buildProcessAddress(runtime, personality)
1436
1444
  const sock = buildSocketPath(runtime, personality, cfg.sockDir)
1437
- if (sessionAlive(sock, identity)) return null // one task per session — wait for its reap
1445
+ if (sessionAlive(sock, identity, env)) return null // one task per session — wait for its reap
1438
1446
  const item = peekEphemeralTask(cfg, identity)
1439
1447
  if (!item) return null
1440
1448
  // Durable drain trace: which item, how deep the queue.
@@ -1540,9 +1548,9 @@ export function lastActiveRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runti
1540
1548
 
1541
1549
  /** Runtimes of `peer` whose session is LIVE right now (lifecycle-local liveness,
1542
1550
  * same `sessionAlive` predicate the wake/attach paths use). */
1543
- function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig): Runtime[] {
1551
+ function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig, env: NodeJS.ProcessEnv = process.env): Runtime[] {
1544
1552
  return peer.runtimes.filter(rt =>
1545
- sessionAlive(buildSocketPath(rt, peer.personality, cfg.sockDir), buildProcessAddress(rt, peer.personality)),
1553
+ sessionAlive(buildSocketPath(rt, peer.personality, cfg.sockDir), buildProcessAddress(rt, peer.personality), env),
1546
1554
  )
1547
1555
  }
1548
1556
 
@@ -1570,9 +1578,9 @@ function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig): Runtime[] {
1570
1578
  * arg) resolve to codex, set the peer's default to codex once; per-command runtime
1571
1579
  * args remain the explicit override.
1572
1580
  */
1573
- export function resolvePeerRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runtime {
1581
+ export function resolvePeerRuntime(peer: PeerRecord, cfg: LifecycleConfig, env: NodeJS.ProcessEnv = process.env): Runtime {
1574
1582
  if (peer.runtimes.length === 1) return peer.runtimes[0]
1575
- const live = liveRuntimesOf(peer, cfg)
1583
+ const live = liveRuntimesOf(peer, cfg, env)
1576
1584
  if (live.includes(peer.runtime)) return peer.runtime
1577
1585
  if (live.length === 1) return live[0]
1578
1586
  return peer.runtime
@@ -1610,7 +1618,7 @@ export async function attachPeer(opts: AttachOptions): Promise<AttachResult> {
1610
1618
  const runtime = opts.runtime ?? resolvePeerRuntime(peer, cfg)
1611
1619
  const identity = buildProcessAddress(runtime, opts.personality)
1612
1620
  const sock = buildSocketPath(runtime, opts.personality, cfg.sockDir)
1613
- if (sessionAlive(sock, identity)) return { ok: true, identity, socketPath: sock, woke: false, runtime }
1621
+ if (sessionAlive(sock, identity, env)) return { ok: true, identity, socketPath: sock, woke: false, runtime }
1614
1622
  const woke = await wakeOrSpawn({ personality: opts.personality, runtime, task: '', resume: true }, { cfg, env })
1615
1623
  if (woke.status === 'FAILED') return { ok: false, reason: woke.reason ?? 'wake failed' }
1616
1624
  return { ok: true, identity, socketPath: sock, woke: true, runtime }
@@ -79,22 +79,23 @@ function monotonicMs(): number {
79
79
  return Number(process.hrtime.bigint() / 1_000_000n)
80
80
  }
81
81
 
82
- function sessionAlive(_sock: string, address: string): boolean {
82
+ function sessionAlive(_sock: string, address: string, env: NodeJS.ProcessEnv = process.env): boolean {
83
83
  // pty-only: a peer's liveness is its supervisor daemon (a cheap pid-file check). `address` IS the
84
84
  // identity. (`_sock` is retained for call-site compatibility; tmux sockets no longer exist.)
85
- return hostSessionAlive(address)
85
+ // env sandboxes the run-dir — an env-injected verb must not read the real fleet's liveness.
86
+ return hostSessionAlive(address, env)
86
87
  }
87
88
 
88
89
  /** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
89
- export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
90
+ export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): boolean {
90
91
  const socketPath = buildSocketPath(runtime, personality, sockDir)
91
- return sessionAlive(socketPath, buildProcessAddress(runtime, personality))
92
+ return sessionAlive(socketPath, buildProcessAddress(runtime, personality), env)
92
93
  }
93
94
 
94
- export function listOnlinePeers(_sockDir = resolveSockDir()): OnlinePeer[] {
95
+ export function listOnlinePeers(_sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): OnlinePeer[] {
95
96
  // pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
96
97
  const out = new Map<string, OnlinePeer>()
97
- for (const h of listHostedPeers()) {
98
+ for (const h of listHostedPeers(env)) {
98
99
  out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
99
100
  }
100
101
  return Array.from(out.values()).sort(