@agfpd/iapeer 0.2.45 → 0.2.47
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 +17 -0
- package/src/cli/index.ts +15 -1
- package/src/launch/adapters/codex.ts +18 -7
- package/src/launch/index.ts +27 -1
- package/src/launch/launch.test.ts +59 -1
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -476,10 +476,27 @@ describe('add-runtime / default-runtime (fleet-switch levers)', () => {
|
|
|
476
476
|
expect(prof.runtime).toBe('claude') // default_runtime NOT flipped
|
|
477
477
|
const rec = findPeer(readPeersIndex({ env: e }), 'aud1')!
|
|
478
478
|
expect(rec.runtimes).toContain('codex')
|
|
479
|
+
expect(rec.runtime).toBe('claude') // registry default NOT flipped (capability ≠ routing; live-caught: upsert's «args.runtime wins» needed the reindex heal)
|
|
479
480
|
// idempotent
|
|
480
481
|
expect((await addRuntime('codex', { peer: 'aud1', env: e }))[0].action).toBe('already')
|
|
481
482
|
})
|
|
482
483
|
|
|
484
|
+
test('add-runtime works from INSIDE another peer session (poisoned PEER_* env) — live-caught 12.06', async () => {
|
|
485
|
+
const e = env()
|
|
486
|
+
e.PEER_PERSONALITY = 'iapeer' // the caller's own identity must not poison the target's init
|
|
487
|
+
e.PEER_RUNTIME = 'claude'
|
|
488
|
+
e.PEER_IDENTITY = 'claude-iapeer'
|
|
489
|
+
const cwd = join(root, 'peers', 'aud3')
|
|
490
|
+
mkdirSync(cwd, { recursive: true })
|
|
491
|
+
await upsertPeer({ personality: 'aud3', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
|
|
492
|
+
const { initPeer } = await import('../init/index.ts')
|
|
493
|
+
const cleanE = { ...e }
|
|
494
|
+
delete cleanE.PEER_PERSONALITY; delete cleanE.PEER_RUNTIME; delete cleanE.PEER_IDENTITY
|
|
495
|
+
await initPeer({ cwd, runtime: 'claude', env: cleanE })
|
|
496
|
+
const outcomes = await addRuntime('codex', { peer: 'aud3', env: e })
|
|
497
|
+
expect(outcomes[0].action).toBe('added')
|
|
498
|
+
})
|
|
499
|
+
|
|
483
500
|
test('add-runtime refuses an infra runtime as the argument', async () => {
|
|
484
501
|
await register('x1')
|
|
485
502
|
await expect(addRuntime('telegram', { peer: 'x1', env: env() })).rejects.toThrow('infra runtime')
|
package/src/cli/index.ts
CHANGED
|
@@ -303,6 +303,14 @@ export async function addRuntime(
|
|
|
303
303
|
const index = readPeersIndex({ env })
|
|
304
304
|
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
305
305
|
if (targets.length === 0) throw new Error(opts.peer ? `peer "${opts.peer}" is not registered` : 'no targets — pass --peer <p> or --all')
|
|
306
|
+
// CROSS-PEER operation: strip the CALLER's identity env (PEER_*) — an operator
|
|
307
|
+
// (or an agent peer) running this from inside their own session would otherwise
|
|
308
|
+
// poison the TARGET's ensurePeerProfile identity check (live-caught on the very
|
|
309
|
+
// first 0.2.45 probe: PEER_PERSONALITY="iapeer" vs target "twpar" → failed).
|
|
310
|
+
const cleanEnv: NodeJS.ProcessEnv = { ...env }
|
|
311
|
+
delete cleanEnv.PEER_PERSONALITY
|
|
312
|
+
delete cleanEnv.PEER_RUNTIME
|
|
313
|
+
delete cleanEnv.PEER_IDENTITY
|
|
306
314
|
const { initPeer } = await import('../init/index.ts')
|
|
307
315
|
const out: AddRuntimeOutcome[] = []
|
|
308
316
|
for (const p of targets) {
|
|
@@ -316,12 +324,18 @@ export async function addRuntime(
|
|
|
316
324
|
}
|
|
317
325
|
try {
|
|
318
326
|
const warns: string[] = []
|
|
319
|
-
await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env, warn: m => warns.push(m) })
|
|
327
|
+
await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env: cleanEnv, warn: m => warns.push(m) })
|
|
320
328
|
out.push({ personality: p.personality, action: 'added', detail: warns.length ? warns.join(' | ') : undefined })
|
|
321
329
|
} catch (e) {
|
|
322
330
|
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
323
331
|
}
|
|
324
332
|
}
|
|
333
|
+
// Self-heal the registry from the locals: provisionPeer's upsert sets the record's
|
|
334
|
+
// default to the PROVISIONED runtime («args.runtime wins» — right for births, wrong
|
|
335
|
+
// here: add-runtime is capability-only and must NOT flip routing). The reindex
|
|
336
|
+
// REPLACE projection restores the local profile's untouched default (live-caught
|
|
337
|
+
// on the 0.2.45 probe: registry default flipped to codex while the local kept claude).
|
|
338
|
+
if (out.some(o => o.action === 'added')) await reindexFromLocals({ env })
|
|
325
339
|
return out
|
|
326
340
|
}
|
|
327
341
|
|
|
@@ -223,17 +223,28 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
223
223
|
},
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
|
-
* Ready for the first message iff the codex TUI
|
|
227
|
-
*
|
|
228
|
-
* - pane includes 'OpenAI Codex' — the TUI splash/header is present.
|
|
226
|
+
* Ready for the first message iff the codex TUI composer is rendered and no
|
|
227
|
+
* startup screen is still up:
|
|
229
228
|
* - pane does NOT include 'Press enter to continue' — the update screen (and
|
|
230
|
-
* other "press enter" startup prompts) is gone
|
|
229
|
+
* other "press enter" startup prompts) is gone (belt-and-suspenders: the
|
|
230
|
+
* boot loop answers known dialogs via bootDialogKeys BEFORE asking this).
|
|
231
231
|
* - some line, trimStart, startsWith '› ' — the rendered input arrow at the
|
|
232
|
-
* start of the
|
|
233
|
-
* input arrow is the stable ready invariant — not any single tip.)
|
|
232
|
+
* start of the composer row. (Codex rotates its top-of-pane tip line, so
|
|
233
|
+
* the input arrow is the stable ready invariant — not any single tip.)
|
|
234
|
+
*
|
|
235
|
+
* DELIBERATELY no 'OpenAI Codex' splash check (the watcher.codexInputReady
|
|
236
|
+
* heritage requirement, removed 12.06): `resume --last` replays the prior
|
|
237
|
+
* transcript ABOVE the composer, and a history taller than the pane scrolls
|
|
238
|
+
* the splash box off the top of capture-pane — the predicate then stays false
|
|
239
|
+
* for the entire boot deadline while the composer sits ready, failing the wake
|
|
240
|
+
* `never-became-ready` and LOSING the triggering message (live incidents
|
|
241
|
+
* 11.06 19:21Z + 12.06 12:07Z, codex-linus). A replayed history message also
|
|
242
|
+
* renders with a leading '› ', so this can fire a frame before the composer
|
|
243
|
+
* activates — benign: the paste/Enter queue in the pty and land in the
|
|
244
|
+
* composer, and the ready-gate still verifies a real model turn (activity
|
|
245
|
+
* mtime advance) before declaring READY.
|
|
234
246
|
*/
|
|
235
247
|
isInputReady(pane: string): boolean {
|
|
236
|
-
if (!pane.includes('OpenAI Codex')) return false
|
|
237
248
|
if (pane.includes('Press enter to continue')) return false
|
|
238
249
|
return pane.split(/\r?\n/).some(line => line.trimStart().startsWith('› '))
|
|
239
250
|
},
|
package/src/launch/index.ts
CHANGED
|
@@ -114,6 +114,22 @@ function sleep(ms: number): Promise<void> {
|
|
|
114
114
|
function fail(identity: string, reason: string): LaunchResult {
|
|
115
115
|
return { status: 'FAILED', identity, process_address: identity, reason }
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The last two non-empty lines of a pane capture, flattened for embedding in a
|
|
120
|
+
* fail reason: quotes → ', whitespace runs collapsed, joined with ' ⏎ ', clipped
|
|
121
|
+
* to 160 chars. '' for an empty/blank capture (caller omits the clause). Exported
|
|
122
|
+
* for unit tests — the boot loop embeds this in `never-became-ready` so the
|
|
123
|
+
* delivery.log err field shows what the TUI was actually displaying.
|
|
124
|
+
*/
|
|
125
|
+
export function paneTail(pane: string): string {
|
|
126
|
+
const lines = pane
|
|
127
|
+
.split(/\r?\n/)
|
|
128
|
+
.map(l => l.replace(/\s+/g, ' ').trim())
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
if (lines.length === 0) return ''
|
|
131
|
+
return lines.slice(-2).join(' ⏎ ').replace(/"/g, "'").slice(0, 160)
|
|
132
|
+
}
|
|
117
133
|
function ready(identity: string): LaunchResult {
|
|
118
134
|
return { status: 'READY', identity, process_address: identity }
|
|
119
135
|
}
|
|
@@ -347,6 +363,7 @@ export const launch: LaunchFn = async (
|
|
|
347
363
|
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
348
364
|
const bootIters = Math.max(1, Math.ceil(cfg.bootDeadlineSecs / 2))
|
|
349
365
|
let delivered = false
|
|
366
|
+
let lastPane = '' // the final capture — surfaced in the never-became-ready reason
|
|
350
367
|
for (let i = 0; i < bootIters && !delivered; i++) {
|
|
351
368
|
await sleep(2000)
|
|
352
369
|
if (!sessionAlive(sock, identity)) {
|
|
@@ -354,6 +371,7 @@ export const launch: LaunchFn = async (
|
|
|
354
371
|
}
|
|
355
372
|
const pane = capturePane(sock, identity)
|
|
356
373
|
if (!pane) continue
|
|
374
|
+
lastPane = pane
|
|
357
375
|
const dialogKeys = adapter.bootDialogKeys(pane)
|
|
358
376
|
if (dialogKeys) {
|
|
359
377
|
tmux(sock, 'send-keys', '-t', identity, ...dialogKeys)
|
|
@@ -390,7 +408,15 @@ export const launch: LaunchFn = async (
|
|
|
390
408
|
}
|
|
391
409
|
}
|
|
392
410
|
if (!delivered) {
|
|
393
|
-
|
|
411
|
+
// Surface WHAT the pane showed when the gate gave up — the 12.06 codex-resume
|
|
412
|
+
// incident (splash scrolled off, composer ready, predicate false for 240 s) was
|
|
413
|
+
// undiagnosable from the bare reason alone. Last two non-empty lines, quotes
|
|
414
|
+
// flattened (the reason lands inside a logfmt-quoted err field), clipped.
|
|
415
|
+
const tail = paneTail(lastPane)
|
|
416
|
+
return fail(
|
|
417
|
+
identity,
|
|
418
|
+
`never-became-ready (stuck at a startup prompt or boot hang)${tail ? `; pane tail: ${tail}` : ''}`,
|
|
419
|
+
)
|
|
394
420
|
}
|
|
395
421
|
|
|
396
422
|
// (7) READY-GATE — the activity proxy must strictly advance past baseline
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { exitCauseHook, exitLogPath, getAdapter, launch } from './index.ts'
|
|
2
|
+
import { exitCauseHook, exitLogPath, getAdapter, launch, paneTail } from './index.ts'
|
|
3
3
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
4
4
|
import { codexAdapter } from './adapters/codex.ts'
|
|
5
5
|
import { telegramAdapter } from './adapters/telegram.ts'
|
|
@@ -74,6 +74,64 @@ describe('codexAdapter.buildArgv', () => {
|
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
describe('codexAdapter.isInputReady', () => {
|
|
78
|
+
test('fresh boot: splash + composer → true', () => {
|
|
79
|
+
const pane = [
|
|
80
|
+
'╭───────────────────────────────╮',
|
|
81
|
+
'│ >_ OpenAI Codex (v0.138.0) │',
|
|
82
|
+
'╰───────────────────────────────╯',
|
|
83
|
+
'› Explain this codebase',
|
|
84
|
+
'gpt-5.5 xhigh · ~/Peers/p',
|
|
85
|
+
].join('\n')
|
|
86
|
+
expect(codexAdapter.isInputReady(pane)).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('resume replay scrolled the splash off — composer alone is READY (12.06 incident)', () => {
|
|
90
|
+
// The live zombie pane shape: history tail + MCP noise + composer, NO
|
|
91
|
+
// 'OpenAI Codex' anywhere in the visible capture. The old predicate required
|
|
92
|
+
// the splash and spun false for the whole 240 s boot deadline.
|
|
93
|
+
const pane = [
|
|
94
|
+
' <message><![CDATA[Компакт прошел?]]></message>',
|
|
95
|
+
' </iap>',
|
|
96
|
+
'• Принял. Ответ пиру не слал: он явно написал, что больше ничего не нужно.',
|
|
97
|
+
'⚠ MCP startup incomplete (failed: iap)',
|
|
98
|
+
'› Explain this codebase',
|
|
99
|
+
'gpt-5.5 xhigh · ~/Peers/p',
|
|
100
|
+
].join('\n')
|
|
101
|
+
expect(pane.includes('OpenAI Codex')).toBe(false) // fixture honesty
|
|
102
|
+
expect(codexAdapter.isInputReady(pane)).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('update screen up → false even with a composer-looking line', () => {
|
|
106
|
+
const pane = ['Update available!', 'Press enter to continue', '› something'].join('\n')
|
|
107
|
+
expect(codexAdapter.isInputReady(pane)).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('no composer row yet (box only, model loading) → false', () => {
|
|
111
|
+
const pane = ['│ >_ OpenAI Codex (v0.138.0) │', '│ model: loading │'].join('\n')
|
|
112
|
+
expect(codexAdapter.isInputReady(pane)).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('bare › without trailing space (a stray glyph) → false', () => {
|
|
116
|
+
expect(codexAdapter.isInputReady('›\nnothing else')).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('paneTail (never-became-ready observability)', () => {
|
|
121
|
+
test('last two non-empty lines, joined, quotes flattened', () => {
|
|
122
|
+
const pane = 'first\n\n middle line \n⚠ MCP "failed"\n› composer\n\n'
|
|
123
|
+
expect(paneTail(pane)).toBe("⚠ MCP 'failed' ⏎ › composer")
|
|
124
|
+
})
|
|
125
|
+
test('single line and empty captures', () => {
|
|
126
|
+
expect(paneTail('only')).toBe('only')
|
|
127
|
+
expect(paneTail('')).toBe('')
|
|
128
|
+
expect(paneTail('\n \n')).toBe('')
|
|
129
|
+
})
|
|
130
|
+
test('clips to 160 chars', () => {
|
|
131
|
+
expect(paneTail('x'.repeat(300)).length).toBe(160)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
77
135
|
describe('telegramAdapter (router — no TUI surface)', () => {
|
|
78
136
|
test('router predicates are trivial', () => {
|
|
79
137
|
expect(telegramAdapter.bootDialogKeys('anything')).toBeNull()
|