@agfpd/iapeer 0.2.63 → 0.2.65

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.2.63",
3
+ "version": "0.2.65",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -470,7 +470,7 @@ describe('compact (resume sleeping dialogue)', () => {
470
470
  wakeCalls.push(args)
471
471
  return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
472
472
  },
473
- controlFn: (personality, runtime, command) => {
473
+ controlFn: async (personality, runtime, command) => {
474
474
  controlCalls.push({ personality, runtime, command })
475
475
  return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
476
476
  },
@@ -495,7 +495,7 @@ describe('compact (resume sleeping dialogue)', () => {
495
495
  const o = await compactPeer('slow', 'codex', {
496
496
  env: e,
497
497
  wakeFn: async args => ({ status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }),
498
- controlFn: (personality, runtime, command) => ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' }),
498
+ controlFn: async (personality, runtime, command) => ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' }),
499
499
  compactDoneFn: () => err('compact did not complete within test'),
500
500
  })
501
501
  expect(o.action).toBe('failed')
package/src/cli/index.ts CHANGED
@@ -48,6 +48,8 @@ import {
48
48
  type WakeResult,
49
49
  } from '../lifecycle/index.ts'
50
50
  import { getAdapter } from '../launch/index.ts'
51
+ import { hostRunDir, hostSessionAlive } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: host-aware attach
52
+ import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
51
53
  import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
52
54
  import { readPeerProfile, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
53
55
  import { peerProfilePath } from '../storage/index.ts'
@@ -583,7 +585,7 @@ export async function compactPeer(
583
585
  }
584
586
  }
585
587
  const baseline = compactDoneBaseline(rt, peer.cwd, { env })
586
- const r = control(personality, rt, { name: 'compact' })
588
+ const r = await control(personality, rt, { name: 'compact' })
587
589
  if (!r.ok) return { personality, runtime: rt, action: 'failed', reason: r.error.message }
588
590
  const waited = waitDone(r.value.controlled.runtime as Runtime, baseline, false)
589
591
  if (waited) return waited
@@ -622,7 +624,7 @@ export async function compactPeer(
622
624
  }
623
625
  clearIdleReaped(cfg, identity)
624
626
  const baseline = compactDoneBaseline(rt, peer.cwd, { env })
625
- const r = control(personality, rt, { name: 'compact' })
627
+ const r = await control(personality, rt, { name: 'compact' })
626
628
  if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
627
629
  const waited = waitDone(rt, baseline, true)
628
630
  if (waited) return waited
@@ -1656,7 +1658,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1656
1658
  // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
1657
1659
  // turn (Escape). UNCONDITIONAL — acts on the live session.
1658
1660
  if (!positionals[0]) return usage(errOut)
1659
- const r = routeControl(positionals[0], positionals[1], { name: verb })
1661
+ const r = await routeControl(positionals[0], positionals[1], { name: verb })
1660
1662
  if (!r.ok) {
1661
1663
  errOut(`${verb}: ${r.error.message}\n`)
1662
1664
  return 1
@@ -1743,6 +1745,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1743
1745
  // reads directly off the log.
1744
1746
  const attachCfg = loadLifecycleConfig(env)
1745
1747
  appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach', identity: r.identity, woke: String(r.woke) }, { env })
1748
+ // SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session has no tmux to attach — hand the operator to
1749
+ // the supervisor client (runSupervisorClient: raw passthrough + repaint-on-attach + Ctrl-]
1750
+ // detach + both reattach port-deps). It calls process.exit on every terminal path, so bracket
1751
+ // attach-end via an exit hook (appendFileSync is signal/exit-safe). Flag-off → tmux attach.
1752
+ if (hostSessionAlive(r.identity)) {
1753
+ process.on('exit', () =>
1754
+ appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: process.exitCode ?? 0 }, { env }),
1755
+ )
1756
+ await runSupervisorClient(hostRunDir(), r.identity)
1757
+ return 0 // unreachable — runSupervisorClient exits on detach / session end
1758
+ }
1746
1759
  // Drop the operator into the session. `env -u TMUX` so a nested attach from
1747
1760
  // inside tmux does not error ("sessions should be nested with care").
1748
1761
  const attachEnv = { ...env }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { spawnSync } from 'child_process'
11
11
  import { attachPeer } from '../lifecycle/index.ts'
12
+ import { hostRunDir, hostSessionAlive } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: host-aware attach
13
+ import { runSupervisorClient } from '../supervisor/client.ts'
12
14
  import { listPeers, type PeerListing } from './index.ts'
13
15
 
14
16
  const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
@@ -138,6 +140,11 @@ export async function runListTui(env: NodeJS.ProcessEnv = process.env): Promise<
138
140
  return 1
139
141
  }
140
142
  stdout.write(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
143
+ // SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session attaches via the supervisor client (no tmux).
144
+ if (hostSessionAlive(r.identity)) {
145
+ await runSupervisorClient(hostRunDir(), r.identity)
146
+ return 0 // unreachable — runSupervisorClient exits on detach / session end
147
+ }
141
148
  const attachEnv = { ...env }
142
149
  delete attachEnv.TMUX
143
150
  const a = spawnSync('tmux', ['-S', r.socketPath, 'attach', '-t', r.identity], {
@@ -214,6 +214,10 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
214
214
 
215
215
  return createComposerDeliveryQueue({
216
216
  env,
217
+ // Spawn-flip Ф0b-3 slice 3c: the HOSTED occupancy default reads <logDir>/<identity>.log — the SAME
218
+ // dir the wake→launch path writes warm-peer pane-logs to (cfg.logDir), shared with the ready-gate
219
+ // flip + the shadow observer. tmux targets ignore it.
220
+ logDir: cfg.logDir,
217
221
  noteLiveTopic,
218
222
  onDelivered,
219
223
  notifyFailed: async (job, reason) => {
@@ -0,0 +1,63 @@
1
+ // Spawn-flip cutover Block 2, Ф0b-2 slice 2 — hosted composer-occupancy DETECTION.
2
+ //
3
+ // paneLogComposerOccupied reads a supervisor-HOSTED session's pane-log model and runs the burn-in-
4
+ // validated composerOccupancyFromModel (REUSED from the leaf, not reimplemented). These suites are
5
+ // boris's slice-2 acceptance: the guard FIRES on a busy-composer model-state and stays cold on an
6
+ // empty / dim-ghost composer — proven by injecting real rendered frames into a pane-log. The end-to-
7
+ // end hold (attached human + warm-deliver respects the guard) is validated with the attach client in
8
+ // Ф0b-3; here we bind the DETECTOR. Bun-native @xterm is loaded dynamically by the function under test.
9
+ import { afterEach, describe, expect, test } from 'bun:test'
10
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { paneLogComposerOccupied } from './readyGateModel.ts'
14
+
15
+ const COLS = 80
16
+ const ROWS = 24
17
+
18
+ let dir: string | null = null
19
+ function paneLogWith(frame: string): string {
20
+ dir = mkdtempSync(join(tmpdir(), 'iapeer-occ-'))
21
+ const log = join(dir, 'pane.log')
22
+ writeFileSync(log, Buffer.from(frame, 'utf8')) // raw bytes — preserve the multibyte ❯ / › glyphs
23
+ return log
24
+ }
25
+ afterEach(() => {
26
+ if (dir) rmSync(dir, { recursive: true, force: true })
27
+ dir = null
28
+ })
29
+
30
+ describe('paneLogComposerOccupied — guard FIRES on a busy hosted composer (slice-2 acceptance)', () => {
31
+ test('claude ❯ with human text → BUSY (true)', async () => {
32
+ const log = paneLogWith('\x1b[24;1H❯ the quick brown fox')
33
+ expect(await paneLogComposerOccupied(log, COLS, ROWS, 'claude')).toBe(true)
34
+ })
35
+
36
+ test('codex › with human text → BUSY (true)', async () => {
37
+ const log = paneLogWith('\x1b[24;1H› reviewer note')
38
+ expect(await paneLogComposerOccupied(log, COLS, ROWS, 'codex')).toBe(true)
39
+ })
40
+ })
41
+
42
+ describe('paneLogComposerOccupied — guard stays COLD on a free composer', () => {
43
+ test('claude ❯ empty → free (false)', async () => {
44
+ const log = paneLogWith('\x1b[24;1H❯ ')
45
+ expect(await paneLogComposerOccupied(log, COLS, ROWS, 'claude')).toBe(false)
46
+ })
47
+
48
+ test('dim-ghost placeholder (grey246 + dim) → NOT human → free (false)', async () => {
49
+ const log = paneLogWith('\x1b[24;1H❯ \x1b[2m\x1b[38;5;246mTry "fix typecheck errors"\x1b[0m')
50
+ expect(await paneLogComposerOccupied(log, COLS, ROWS, 'claude')).toBe(false)
51
+ })
52
+
53
+ test('no prompt glyph rendered → free (false)', async () => {
54
+ const log = paneLogWith('\x1b[24;1Hjust some output, no composer')
55
+ expect(await paneLogComposerOccupied(log, COLS, ROWS, 'claude')).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('paneLogComposerOccupied — uncertainty is conservative (BUSY)', () => {
60
+ test('missing pane-log → BUSY (true): never paste into a maybe-occupied composer', async () => {
61
+ expect(await paneLogComposerOccupied(join(tmpdir(), 'iapeer-occ-nonexistent.log'), COLS, ROWS, 'claude')).toBe(true)
62
+ })
63
+ })
@@ -14,13 +14,14 @@
14
14
  // decides WHEN/HOW-MANY and hands launch a fully-resolved LaunchSpec + adapter.
15
15
 
16
16
  import { mkdirSync } from 'fs'
17
- import { homedir } from 'os'
18
17
  import { dirname } from 'path'
19
18
  import { spawnSync } from 'child_process'
20
- import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
21
- import { readLaunchEnv } from '../storage/index.ts'
19
+ import { type Runtime } from '../core/constants.ts'
20
+ import { buildLaunchInvocation } from './invocation.ts'
22
21
  import { canaryChannel, dismissCanary, ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
23
22
  import { hardenCmdLogDir, prepareCmdLogDir } from './cmdlog.ts'
23
+ import { logReadyGateCrossCheck, readyGatePtyFlipEnabled, readyGateViewport } from './readyGateModel.ts'
24
+ import { deliverHosted, hostSessionAlive, ptyHostEnabled, startPtyHost, waitHostReady } from './ptyHost.ts'
24
25
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
25
26
  import { claudeAdapter } from './adapters/claude.ts'
26
27
  import { codexAdapter } from './adapters/codex.ts'
@@ -248,6 +249,53 @@ export const launch: LaunchFn = async (
248
249
  // at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
249
250
  mkdirSync(dirname(sock), { recursive: true })
250
251
 
252
+ // ── SPAWN-FLIP (cutover Block 2, Ф0a) — per-peer `.pty-host` flag, DARK by default ────────────────
253
+ // A flag-on tui peer is HOSTED by the supervisor (it owns the pty + boot-driver + pane-log) instead
254
+ // of `tmux new-session`. The supervisor serves the SAME launch-composed argv+env (buildLaunchInvocation,
255
+ // the single source the tmux path also uses), so the hosted session is parity-identical. Readiness is
256
+ // read off the pane-log model (the supervisor's boot-driver answers startup dialogs internally);
257
+ // delivery goes over the supervisor socket. UNCONDITIONAL SPAWN-FALLBACK: any host-bringup failure
258
+ // falls through to the tmux path below — a flipped peer is NEVER worse than tmux ON SPAWN. Flag-off →
259
+ // this whole block is skipped and the tmux path is byte-identical (golden). Routers are never hosted
260
+ // (infra, launchd-owned). @xterm stays out of THIS process (ptyHost: @xterm-free start + deliver +
261
+ // sessionAlive; readiness's @xterm import is dynamic and only loads here, flag-on).
262
+ if (adapter.kind === 'tui' && ptyHostEnabled(cfg.logDir, identity)) {
263
+ const invocation = buildLaunchInvocation(spec, adapter, cfg)
264
+ mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
265
+ const paneLogPath = `${cfg.logDir}/${identity}.log`
266
+ const host = await startPtyHost({
267
+ identity,
268
+ runtime: spec.runtime,
269
+ cwd,
270
+ paneLogPath,
271
+ exitLogPath: cfg.exitLogDir ? exitLogPath(cfg.exitLogDir) : undefined,
272
+ invocation,
273
+ })
274
+ if (host.ok) {
275
+ // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
276
+ // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
277
+ // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
278
+ const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
279
+ const isReady = await waitHostReady(
280
+ { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs },
281
+ sleep,
282
+ )
283
+ if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
284
+ if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
285
+ const d = await deliverHosted(identity, firstMessage)
286
+ if (!d.ok) return fail(identity, `hosted deliver failed: ${d.error}`)
287
+ const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
288
+ while (Date.now() < readyDeadline) {
289
+ await sleep(2000)
290
+ if (!hostSessionAlive(identity)) return fail(identity, 'hosted session vanished during ready-gate')
291
+ if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
292
+ }
293
+ return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
294
+ }
295
+ // SPAWN-FALLBACK — host bringup failed; record it and fall through to the tmux path (never worse).
296
+ appendLifecycleEvent(cfg.exitLogDir, { ev: 'pty-host-fallback', identity, reason: host.error }, { env })
297
+ }
298
+
251
299
  // (1) Pre-clean any stale tmux server on this socket, then launch detached.
252
300
  // Silence the server-death canary EXPLICITLY first — this teardown is
253
301
  // deliberate: signal the channel (client exits 0) AND dismiss the sh
@@ -266,27 +314,13 @@ export const launch: LaunchFn = async (
266
314
  // PEER_START_ARGS (APPENDED after the adapter's base flags — extraArgs is last
267
315
  // in every buildArgv) and extra child env. spec.extraArgs (an explicit caller
268
316
  // override) comes first, then launch.env's; both land after the base flags.
269
- const launchEnv = readLaunchEnv(cwd, spec.runtime)
270
- const specWithArgs: LaunchSpec =
271
- launchEnv.startArgs.length > 0
272
- ? { ...spec, extraArgs: [...(spec.extraArgs ?? []), ...launchEnv.startArgs] }
273
- : spec
274
- const runtimeCmd = adapter.buildArgv(specWithArgs, cfg).map(shellQuote).join(' ')
275
- // Child env: base env + launch.env extras + the identity ABI (identity LAST so a
276
- // stray PEER_* in launch.env can never override the resolved identity) + PATH.
277
- const childEnv: NodeJS.ProcessEnv = {
278
- ...env,
279
- ...launchEnv.env,
280
- // codex token-free MCP import: the daemon's bearer env var is set to the PUBLIC,
281
- // non-secret stub so codex flips authStatus unsupported→bearer_token and imports
282
- // send_to_peer (the OPEN daemon ignores it, authenticating by PEER_IDENTITY below
283
- // via env_http_headers). Only for codex — claude carries its identity in .mcp.json.
284
- ...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
285
- PEER_PERSONALITY: spec.personality,
286
- PEER_RUNTIME: spec.runtime,
287
- PEER_IDENTITY: identity,
288
- PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
289
- }
317
+ // argv + child env are composed by buildLaunchInvocation (invocation.ts) — the SINGLE source shared
318
+ // with the supervisor serving path (cutover Block 2) so a served session is byte-identical to this
319
+ // tmux-launched one. The tmux path shell-quotes+joins the argv into the new-session string; the
320
+ // supervisor path passes the same argv array to Bun.spawn. (PEER_START_ARGS append, codex bearer
321
+ // stub, identity ABI, PATH — all live there now, byte-for-byte unchanged.)
322
+ const { argv: runtimeArgv, env: childEnv } = buildLaunchInvocation(spec, adapter, cfg)
323
+ const runtimeCmd = runtimeArgv.map(shellQuote).join(' ')
290
324
  // (2.4) tmux command-log (killer-hunt socket-command layer, cmdlog.ts): WARM
291
325
  // agent sessions start their server with `-v` so every client command —
292
326
  // incl. a killer's `kill-server`/`kill-session`, invisible to signal
@@ -385,7 +419,29 @@ export const launch: LaunchFn = async (
385
419
  tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
386
420
  continue
387
421
  }
388
- if (adapter.isInputReady(pane)) {
422
+ // Ф1 READY-GATE SOURCE FLIP (per-peer flag, DARK by default). When the peer's flip flag is set,
423
+ // run the SAME isInputReady predicate on the pty-MODEL viewport (validated 0-divergence vs
424
+ // capture-pane by the burn-in gate, incl. the codex resume-with-history splash-off-screen center)
425
+ // instead of the capture-pane scrape, with an UNCONDITIONAL capture-pane fallback. This is the
426
+ // ONLY changed input — dialog detection (above) and delivery + delivery-confirm (below + transport)
427
+ // are untouched. Flag OFF → readyView === pane (byte-identical to today).
428
+ let readyView = pane
429
+ if (readyGatePtyFlipEnabled(cfg.logDir, identity)) {
430
+ const geo = tmux(sock, 'display', '-p', '-t', identity, '#{pane_width}x#{pane_height}')
431
+ const [c, r] = geo.ok ? geo.out.trim().split('x').map(Number) : [0, 0]
432
+ readyView = await readyGateViewport({ logDir: cfg.logDir, identity, fallback: pane, cols: c ?? 0, rows: r ?? 0 })
433
+ // SERVING-path cross-check (post-flip soak gate): the model verdict is AUTHORITATIVE (readyView);
434
+ // record what capture-pane WOULD have said, to prove 0 model-vs-capture divergence live in the
435
+ // serving path. Observability only — never affects the ready decision.
436
+ logReadyGateCrossCheck(cfg.exitLogDir, {
437
+ identity,
438
+ modelReady: adapter.isInputReady(readyView),
439
+ captureReady: adapter.isInputReady(pane),
440
+ geom: `${c ?? 0}x${r ?? 0}`,
441
+ nowISO: new Date().toISOString(),
442
+ })
443
+ }
444
+ if (adapter.isInputReady(readyView)) {
389
445
  if (!hasMessage) return ready(identity) // bare bring-up — session up, no message
390
446
  // Boot delivery = load-buffer → paste-buffer -p → Enter (Ф-#8b hardening):
391
447
  // the SAME mechanism warm delivery uses (transport.deliverViaTmux), replacing
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { homedir } from 'os'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, dirname } from 'node:path'
6
+ import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER } from '../core/constants.ts'
7
+ import { readLaunchEnv, peerLaunchEnvPath } from '../storage/index.ts'
8
+ import { claudeAdapter } from './adapters/claude.ts'
9
+ import { codexAdapter } from './adapters/codex.ts'
10
+ import { buildLaunchInvocation } from './invocation.ts'
11
+ import type { LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from './types.ts'
12
+
13
+ // NON-CIRCULAR GOLDEN (boris's bar): the ORACLE is the PRE-extraction inline build copied VERBATIM
14
+ // from launch/index.ts @595b0f4 — the source of truth is the OLD code, NOT buildLaunchInvocation.
15
+ // The differential test asserts the extracted function reproduces the oracle byte-for-byte; the
16
+ // independent contract assertions (hardcoded from the contract, not from the function's output) catch
17
+ // the case where oracle and function are wrong together. Two anchors: differential catches a
18
+ // reorganization drift, contract catches a shared error.
19
+ function oracleInline(
20
+ spec: LaunchSpec,
21
+ adapter: RuntimeAdapter,
22
+ cfg: LaunchAdapterConfig & { env?: NodeJS.ProcessEnv },
23
+ ): { argv: string[]; env: NodeJS.ProcessEnv } {
24
+ const env = cfg.env ?? process.env
25
+ const cwd = spec.cwd
26
+ const identity = spec.identity
27
+ const launchEnv = readLaunchEnv(cwd, spec.runtime)
28
+ const specWithArgs: LaunchSpec =
29
+ launchEnv.startArgs.length > 0
30
+ ? { ...spec, extraArgs: [...(spec.extraArgs ?? []), ...launchEnv.startArgs] }
31
+ : spec
32
+ const runtimeArgv = adapter.buildArgv(specWithArgs, cfg)
33
+ const childEnv: NodeJS.ProcessEnv = {
34
+ ...env,
35
+ ...launchEnv.env,
36
+ ...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
37
+ PEER_PERSONALITY: spec.personality,
38
+ PEER_RUNTIME: spec.runtime,
39
+ PEER_IDENTITY: identity,
40
+ PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
41
+ }
42
+ return { argv: runtimeArgv, env: childEnv }
43
+ }
44
+
45
+ const CFG: LaunchAdapterConfig & { env: NodeJS.ProcessEnv } = {
46
+ claudeBin: '/bin/claude',
47
+ codexBin: '/bin/codex',
48
+ env: { PATH: '/test/path', BASE_VAR: 'base' },
49
+ }
50
+
51
+ function spec(over: Partial<LaunchSpec>): LaunchSpec {
52
+ return {
53
+ personality: 'p',
54
+ runtime: 'claude',
55
+ cwd: '/nonexistent-cwd-no-launchenv',
56
+ identity: 'claude-p',
57
+ socketPath: '/tmp/s.sock',
58
+ extraArgs: [],
59
+ ...over,
60
+ }
61
+ }
62
+
63
+ const adapterFor = (s: LaunchSpec): RuntimeAdapter => (s.runtime === 'codex' ? codexAdapter : claudeAdapter)
64
+
65
+ // Representative specs: both runtimes × {bare, with system-prompt, with resume + extraArgs}.
66
+ const SPECS: Array<{ name: string; spec: LaunchSpec }> = [
67
+ { name: 'claude bare', spec: spec({ runtime: 'claude', identity: 'claude-p' }) },
68
+ {
69
+ name: 'claude + system-prompt + resume + extras',
70
+ spec: spec({ runtime: 'claude', identity: 'claude-p', systemPromptFile: '/sp.md', resume: true, resumeRef: 'uuid-9', extraArgs: ['--foo'] }),
71
+ },
72
+ { name: 'codex bare', spec: spec({ runtime: 'codex', identity: 'codex-p' }) },
73
+ {
74
+ name: 'codex + system-prompt + resume',
75
+ spec: spec({ runtime: 'codex', identity: 'codex-p', systemPromptFile: '/sp.md', resume: true }),
76
+ },
77
+ ]
78
+
79
+ describe('buildLaunchInvocation — differential vs the verbatim pre-extraction oracle', () => {
80
+ for (const { name, spec: s } of SPECS) {
81
+ test(`byte-identical to the inline build: ${name}`, () => {
82
+ const got = buildLaunchInvocation(s, adapterFor(s), CFG)
83
+ const want = oracleInline(s, adapterFor(s), CFG)
84
+ expect(got.argv).toEqual(want.argv)
85
+ expect(got.env).toEqual(want.env)
86
+ })
87
+ }
88
+
89
+ test('the launch.env merge branch (non-empty startArgs + extra env) is byte-identical too', () => {
90
+ const cwd = mkdtempSync(join(tmpdir(), 'inv-launchenv-'))
91
+ try {
92
+ const p = peerLaunchEnvPath(cwd, 'codex')
93
+ mkdirSync(dirname(p), { recursive: true })
94
+ writeFileSync(p, 'PEER_START_ARGS=--from-env-a --from-env-b\nEXTRA_VAR=xyz\n')
95
+ const s = spec({ runtime: 'codex', identity: 'codex-p', cwd, extraArgs: ['--explicit'] })
96
+ const got = buildLaunchInvocation(s, codexAdapter, CFG)
97
+ const want = oracleInline(s, codexAdapter, CFG)
98
+ expect(got.argv).toEqual(want.argv)
99
+ expect(got.env).toEqual(want.env)
100
+ // sanity on the branch itself: explicit extras precede launch.env startArgs, both after base flags
101
+ expect(got.argv).toContain('--explicit')
102
+ expect(got.argv).toContain('--from-env-a')
103
+ expect(got.env.EXTRA_VAR).toBe('xyz')
104
+ } finally {
105
+ rmSync(cwd, { recursive: true, force: true })
106
+ }
107
+ })
108
+ })
109
+
110
+ // Independent contract anchors — expectations come from the CONTRACT, not from the function output.
111
+ describe('buildLaunchInvocation — identity ABI + system-prompt swap (contract values)', () => {
112
+ test('codex carries the non-secret IAPEER_BEARER stub; claude does NOT (identity in .mcp.json)', () => {
113
+ expect(buildLaunchInvocation(spec({ runtime: 'codex', identity: 'codex-p' }), codexAdapter, CFG).env[CODEX_BEARER_ENV_VAR]).toBe(
114
+ CODEX_DUMMY_BEARER,
115
+ )
116
+ expect(
117
+ buildLaunchInvocation(spec({ runtime: 'claude', identity: 'claude-p' }), claudeAdapter, CFG).env[CODEX_BEARER_ENV_VAR],
118
+ ).toBeUndefined()
119
+ })
120
+
121
+ test('the identity ABI is present and matches the spec', () => {
122
+ const { env } = buildLaunchInvocation(spec({ runtime: 'codex', personality: 'boris', identity: 'codex-boris' }), codexAdapter, CFG)
123
+ expect(env.PEER_PERSONALITY).toBe('boris')
124
+ expect(env.PEER_RUNTIME).toBe('codex')
125
+ expect(env.PEER_IDENTITY).toBe('codex-boris')
126
+ })
127
+
128
+ test('the per-session system-prompt swap rides in argv (codex -c, claude --system-prompt-file)', () => {
129
+ const codexArgv = buildLaunchInvocation(spec({ runtime: 'codex', identity: 'codex-p', systemPromptFile: '/sp.md' }), codexAdapter, CFG).argv
130
+ expect(codexArgv).toContain('model_instructions_file=/sp.md')
131
+ const claudeArgv = buildLaunchInvocation(spec({ runtime: 'claude', identity: 'claude-p', systemPromptFile: '/sp.md' }), claudeAdapter, CFG).argv
132
+ expect(claudeArgv.join(' ')).toContain('--system-prompt-file /sp.md')
133
+ })
134
+ })
@@ -0,0 +1,56 @@
1
+ // buildLaunchInvocation — the argv + child-env composition shared by the tmux launch path and the
2
+ // (cutover Block 2) supervisor serving path. Factored out of launch()'s inline build so BOTH produce
3
+ // a BYTE-IDENTICAL invocation from ONE source — parity by construction (a supervisor-served session
4
+ // must be indistinguishable from a tmux-launched one: same flags, same env, same identity). PURE
5
+ // composition: reads the per-peer launch.env + assembles the identity ABI env; no spawn, no tmux.
6
+ // The tmux path shell-quotes the argv into a new-session string; the supervisor path hands the argv
7
+ // array straight to Bun.spawn — same argv, same env, different transport.
8
+ import { homedir } from 'os'
9
+ import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER } from '../core/constants.ts'
10
+ import { readLaunchEnv } from '../storage/index.ts'
11
+ import type { LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from './types.ts'
12
+
13
+ export interface LaunchInvocation {
14
+ /** Runtime argv: binary + flags, including the per-session system-prompt swap (claude
15
+ * --system-prompt-file / codex -c model_instructions_file=) and the appended PEER_START_ARGS.
16
+ * The tmux path shell-quotes+joins it; the supervisor path passes it to Bun.spawn verbatim. */
17
+ argv: string[]
18
+ /** Full child environment: base env + per-peer launch.env extras + (codex) the non-secret bearer
19
+ * stub + the identity ABI (PEER_PERSONALITY/RUNTIME/IDENTITY, written LAST so a stray PEER_* in
20
+ * launch.env can never override the resolved identity) + PATH. */
21
+ env: NodeJS.ProcessEnv
22
+ }
23
+
24
+ /**
25
+ * Compose the runtime invocation (argv + child env) for a fully-resolved spec — the single source
26
+ * for both the tmux launch path and the supervisor serving path. PEER_START_ARGS from launch.env
27
+ * append AFTER the adapter's base flags (extraArgs is last in every buildArgv); an explicit
28
+ * spec.extraArgs (caller override) comes first, then launch.env's.
29
+ */
30
+ export function buildLaunchInvocation(
31
+ spec: LaunchSpec,
32
+ adapter: RuntimeAdapter,
33
+ cfg: LaunchAdapterConfig & { env?: NodeJS.ProcessEnv },
34
+ ): LaunchInvocation {
35
+ const env = cfg.env ?? process.env
36
+ const launchEnv = readLaunchEnv(spec.cwd, spec.runtime)
37
+ const specWithArgs: LaunchSpec =
38
+ launchEnv.startArgs.length > 0
39
+ ? { ...spec, extraArgs: [...(spec.extraArgs ?? []), ...launchEnv.startArgs] }
40
+ : spec
41
+ const argv = adapter.buildArgv(specWithArgs, cfg)
42
+ const childEnv: NodeJS.ProcessEnv = {
43
+ ...env,
44
+ ...launchEnv.env,
45
+ // codex token-free MCP import: the bearer env var is set to the PUBLIC, non-secret stub so codex
46
+ // flips authStatus unsupported→bearer_token and imports send_to_peer (the OPEN daemon ignores it,
47
+ // authenticating by PEER_IDENTITY below via env_http_headers). Only for codex — claude carries its
48
+ // identity in .mcp.json.
49
+ ...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
50
+ PEER_PERSONALITY: spec.personality,
51
+ PEER_RUNTIME: spec.runtime,
52
+ PEER_IDENTITY: spec.identity,
53
+ PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
54
+ }
55
+ return { argv, env: childEnv }
56
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { ptyHostEnabled } from './ptyHost.ts'
6
+ import { readyGatePtyFlipEnabled } from './readyGateModel.ts'
7
+
8
+ describe('ptyHostEnabled — the spawn-flip per-peer flag', () => {
9
+ test('off by default; on when the .pty-host marker exists', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'ptyhost-flag-'))
11
+ try {
12
+ expect(ptyHostEnabled(dir, 'codex-x')).toBe(false)
13
+ writeFileSync(join(dir, 'codex-x.pty-host'), '')
14
+ expect(ptyHostEnabled(dir, 'codex-x')).toBe(true)
15
+ expect(ptyHostEnabled(dir, 'codex-y')).toBe(false) // per-peer, not global
16
+ } finally {
17
+ rmSync(dir, { recursive: true, force: true })
18
+ }
19
+ })
20
+
21
+ test('DISTINCT from the ready-gate flip marker — a readygate-flipped peer is NOT pty-hosted', () => {
22
+ const dir = mkdtempSync(join(tmpdir(), 'ptyhost-distinct-'))
23
+ try {
24
+ writeFileSync(join(dir, 'codex-perplex.pty-readygate'), '') // ready-gate flipped, still tmux-hosted
25
+ expect(readyGatePtyFlipEnabled(dir, 'codex-perplex')).toBe(true)
26
+ expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(false) // the .pty-host marker is separate
27
+ writeFileSync(join(dir, 'codex-perplex.pty-host'), '')
28
+ expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true) // now supervisor-hosted too
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true })
31
+ }
32
+ })
33
+ })