@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 input surface is rendered and
227
- * no startup screen is still up (watcher.codexInputReady, 263-269):
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 input row. (Codex rotates its top-of-pane tip line, so 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
  },
@@ -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
- return fail(identity, 'never-became-ready (stuck at a startup prompt or boot hang)')
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()