@agfpd/iapeer 0.2.64 → 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 +1 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/index.ts +16 -3
- package/src/cli/listTui.ts +7 -0
- package/src/daemon/main.ts +4 -0
- package/src/launch/hostComposerOccupied.test.ts +63 -0
- package/src/launch/index.ts +57 -24
- package/src/launch/invocation.test.ts +134 -0
- package/src/launch/invocation.ts +56 -0
- package/src/launch/ptyHost.test.ts +33 -0
- package/src/launch/ptyHost.ts +148 -0
- package/src/launch/readyGateModel.ts +47 -6
- package/src/lifecycle/index.ts +11 -1
- package/src/shadow/enumerate-skip.test.ts +65 -0
- package/src/shadow/index.ts +13 -37
- package/src/shadow/render.ts +60 -0
- package/src/supervisor/attached-marker.test.ts +84 -0
- package/src/supervisor/boot.test.ts +66 -0
- package/src/supervisor/boot.ts +34 -0
- package/src/supervisor/daemon.test.ts +66 -1
- package/src/supervisor/daemon.ts +146 -7
- package/src/supervisor/deliver.test.ts +58 -0
- package/src/supervisor/deliver.ts +120 -0
- package/src/supervisor/index.ts +76 -6
- package/src/supervisor/panelog.test.ts +95 -0
- package/src/supervisor/paths.ts +9 -0
- package/src/supervisor/protocol.test.ts +91 -0
- package/src/supervisor/protocol.ts +49 -0
- package/src/transport/composerQueue.test.ts +30 -0
- package/src/transport/hostControl.test.ts +87 -0
- package/src/transport/hostDeliver.test.ts +102 -0
- package/src/transport/index.ts +186 -24
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -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 }
|
package/src/cli/listTui.ts
CHANGED
|
@@ -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], {
|
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/launch/index.ts
CHANGED
|
@@ -14,14 +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 {
|
|
21
|
-
import {
|
|
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'
|
|
24
23
|
import { logReadyGateCrossCheck, readyGatePtyFlipEnabled, readyGateViewport } from './readyGateModel.ts'
|
|
24
|
+
import { deliverHosted, hostSessionAlive, ptyHostEnabled, startPtyHost, waitHostReady } from './ptyHost.ts'
|
|
25
25
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
26
26
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
27
27
|
import { codexAdapter } from './adapters/codex.ts'
|
|
@@ -249,6 +249,53 @@ export const launch: LaunchFn = async (
|
|
|
249
249
|
// at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
|
|
250
250
|
mkdirSync(dirname(sock), { recursive: true })
|
|
251
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
|
+
|
|
252
299
|
// (1) Pre-clean any stale tmux server on this socket, then launch detached.
|
|
253
300
|
// Silence the server-death canary EXPLICITLY first — this teardown is
|
|
254
301
|
// deliberate: signal the channel (client exits 0) AND dismiss the sh
|
|
@@ -267,27 +314,13 @@ export const launch: LaunchFn = async (
|
|
|
267
314
|
// PEER_START_ARGS (APPENDED after the adapter's base flags — extraArgs is last
|
|
268
315
|
// in every buildArgv) and extra child env. spec.extraArgs (an explicit caller
|
|
269
316
|
// override) comes first, then launch.env's; both land after the base flags.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
// stray PEER_* in launch.env can never override the resolved identity) + PATH.
|
|
278
|
-
const childEnv: NodeJS.ProcessEnv = {
|
|
279
|
-
...env,
|
|
280
|
-
...launchEnv.env,
|
|
281
|
-
// codex token-free MCP import: the daemon's bearer env var is set to the PUBLIC,
|
|
282
|
-
// non-secret stub so codex flips authStatus unsupported→bearer_token and imports
|
|
283
|
-
// send_to_peer (the OPEN daemon ignores it, authenticating by PEER_IDENTITY below
|
|
284
|
-
// via env_http_headers). Only for codex — claude carries its identity in .mcp.json.
|
|
285
|
-
...(spec.runtime === 'codex' ? { [CODEX_BEARER_ENV_VAR]: CODEX_DUMMY_BEARER } : {}),
|
|
286
|
-
PEER_PERSONALITY: spec.personality,
|
|
287
|
-
PEER_RUNTIME: spec.runtime,
|
|
288
|
-
PEER_IDENTITY: identity,
|
|
289
|
-
PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
|
|
290
|
-
}
|
|
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(' ')
|
|
291
324
|
// (2.4) tmux command-log (killer-hunt socket-command layer, cmdlog.ts): WARM
|
|
292
325
|
// agent sessions start their server with `-v` so every client command —
|
|
293
326
|
// incl. a killer's `kill-server`/`kill-session`, invisible to signal
|
|
@@ -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
|
+
})
|