@agfpd/iapeer 0.2.44 → 0.2.46
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 +76 -1
- package/src/cli/index.ts +155 -1
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
|
7
7
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import { formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
10
|
+
import { addRuntime, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
@@ -451,3 +451,78 @@ describe('new (unconditional fresh restart)', () => {
|
|
|
451
451
|
await expect(newPeer('ghost-nobody', undefined, { env: e })).rejects.toThrow('not registered')
|
|
452
452
|
})
|
|
453
453
|
})
|
|
454
|
+
|
|
455
|
+
// ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
|
|
456
|
+
|
|
457
|
+
describe('add-runtime / default-runtime (fleet-switch levers)', () => {
|
|
458
|
+
test('add-runtime merges the runtime into profile+registry; idempotent; infra peers skipped', async () => {
|
|
459
|
+
const e = env()
|
|
460
|
+
// a claude-only peer with a REAL cwd (initPeer scaffolds it)
|
|
461
|
+
const cwd = join(root, 'peers', 'aud1')
|
|
462
|
+
mkdirSync(cwd, { recursive: true })
|
|
463
|
+
await upsertPeer({ personality: 'aud1', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
|
|
464
|
+
const { initPeer } = await import('../init/index.ts')
|
|
465
|
+
await initPeer({ cwd, runtime: 'claude', env: e }) // give it a local profile (claude-only)
|
|
466
|
+
// an infra peer in the registry — must be skipped by --all
|
|
467
|
+
await register('tginfra', 'telegram', 'natural')
|
|
468
|
+
const outcomes = await addRuntime('codex', { all: true, env: e })
|
|
469
|
+
const aud1 = outcomes.find(o => o.personality === 'aud1')!
|
|
470
|
+
expect(aud1.action).toBe('added')
|
|
471
|
+
expect(outcomes.find(o => o.personality === 'tginfra')!.action).toBe('skipped-infra-peer')
|
|
472
|
+
// profile + registry both carry codex now; default untouched
|
|
473
|
+
const { readPeerProfile } = await import('../identity/index.ts')
|
|
474
|
+
const prof = readPeerProfile(cwd)!
|
|
475
|
+
expect(prof.runtimes).toContain('codex')
|
|
476
|
+
expect(prof.runtime).toBe('claude') // default_runtime NOT flipped
|
|
477
|
+
const rec = findPeer(readPeersIndex({ env: e }), 'aud1')!
|
|
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)
|
|
480
|
+
// idempotent
|
|
481
|
+
expect((await addRuntime('codex', { peer: 'aud1', env: e }))[0].action).toBe('already')
|
|
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
|
+
|
|
500
|
+
test('add-runtime refuses an infra runtime as the argument', async () => {
|
|
501
|
+
await register('x1')
|
|
502
|
+
await expect(addRuntime('telegram', { peer: 'x1', env: env() })).rejects.toThrow('infra runtime')
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('default-runtime flips the primary (profile + registry in one command); refuses undeclared; symmetric back', async () => {
|
|
506
|
+
const e = env()
|
|
507
|
+
const cwd = join(root, 'peers', 'aud2')
|
|
508
|
+
mkdirSync(cwd, { recursive: true })
|
|
509
|
+
await upsertPeer({ personality: 'aud2', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
|
|
510
|
+
const { initPeer } = await import('../init/index.ts')
|
|
511
|
+
await initPeer({ cwd, runtime: 'claude', env: e })
|
|
512
|
+
// undeclared codex → explicit refusal with the add-runtime hint
|
|
513
|
+
const refused = await defaultRuntime('codex', { peer: 'aud2', env: e })
|
|
514
|
+
expect(refused[0].action).toBe('refused-undeclared-runtime')
|
|
515
|
+
expect(refused[0].detail).toContain('add-runtime')
|
|
516
|
+
// declare codex, then flip
|
|
517
|
+
await addRuntime('codex', { peer: 'aud2', env: e })
|
|
518
|
+
expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
|
|
519
|
+
const { readPeerProfile } = await import('../identity/index.ts')
|
|
520
|
+
expect(readPeerProfile(cwd)!.runtime).toBe('codex')
|
|
521
|
+
expect(readPeerProfile(cwd)!.runtimes[0]).toBe('codex') // normalizeRuntimes prepends the default
|
|
522
|
+
expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('codex') // registry healed same-command
|
|
523
|
+
// idempotent + symmetric revert
|
|
524
|
+
expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('already')
|
|
525
|
+
expect((await defaultRuntime('claude', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
|
|
526
|
+
expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('claude')
|
|
527
|
+
})
|
|
528
|
+
})
|
package/src/cli/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
} from '../lifecycle/index.ts'
|
|
48
48
|
import { getAdapter } from '../launch/index.ts'
|
|
49
49
|
import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
50
|
-
import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
50
|
+
import { readPeerProfile, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
|
|
51
51
|
import { peerProfilePath } from '../storage/index.ts'
|
|
52
52
|
import {
|
|
53
53
|
isConformant,
|
|
@@ -272,6 +272,137 @@ export function startPeer(personality: string, runtime: string | undefined, opts
|
|
|
272
272
|
return out
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
export interface AddRuntimeOutcome {
|
|
276
|
+
personality: string
|
|
277
|
+
action: 'added' | 'already' | 'skipped-infra-peer' | 'failed'
|
|
278
|
+
detail?: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* add-runtime <runtime> (--peer <p> | --all) — give EXISTING peers an additional
|
|
283
|
+
* AGENTIC runtime in one command (codex-parity audit, директива Артура 12.06: the
|
|
284
|
+
* fleet-switch enabler — 14/23 peers were claude-only with nothing to switch to).
|
|
285
|
+
* Per target: initPeer({cwd, runtime}) — ensurePeerProfile MERGES runtimes (the
|
|
286
|
+
* default_runtime lever is deliberately untouched — capability ≠ routing flip; see
|
|
287
|
+
* `default-runtime`), scaffolds the runtime scope, and the codex side runs its
|
|
288
|
+
* whole birth chain: cwd pre-trust, native-memory lever, memory provision
|
|
289
|
+
* (occasion=birth), host-wide MCP block + update-check-off. Idempotent by
|
|
290
|
+
* construction (merge + append-if-absent everywhere). Infra PEERS (telegram/
|
|
291
|
+
* notifier defaults) are skipped — adding an agentic runtime to a router peer is
|
|
292
|
+
* an operator decision, not a sweep.
|
|
293
|
+
*/
|
|
294
|
+
export async function addRuntime(
|
|
295
|
+
runtime: string,
|
|
296
|
+
opts: CliEnvOptions & { peer?: string; all?: boolean },
|
|
297
|
+
): Promise<AddRuntimeOutcome[]> {
|
|
298
|
+
const env = opts.env ?? process.env
|
|
299
|
+
if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
300
|
+
if (isInfraRuntime(runtime)) {
|
|
301
|
+
throw new Error(`"${runtime}" is an infra runtime — infra presence is operator-add via \`iapeer create\` (plist semantics), not a sweep`)
|
|
302
|
+
}
|
|
303
|
+
const index = readPeersIndex({ env })
|
|
304
|
+
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
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
|
|
314
|
+
const { initPeer } = await import('../init/index.ts')
|
|
315
|
+
const out: AddRuntimeOutcome[] = []
|
|
316
|
+
for (const p of targets) {
|
|
317
|
+
if (isInfraRuntime(p.runtime)) {
|
|
318
|
+
out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (p.runtimes.includes(runtime as Runtime)) {
|
|
322
|
+
out.push({ personality: p.personality, action: 'already' })
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const warns: string[] = []
|
|
327
|
+
await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env: cleanEnv, warn: m => warns.push(m) })
|
|
328
|
+
out.push({ personality: p.personality, action: 'added', detail: warns.length ? warns.join(' | ') : undefined })
|
|
329
|
+
} catch (e) {
|
|
330
|
+
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
331
|
+
}
|
|
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 })
|
|
339
|
+
return out
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface DefaultRuntimeOutcome {
|
|
343
|
+
personality: string
|
|
344
|
+
action: 'flipped' | 'already' | 'refused-undeclared-runtime' | 'skipped-infra-peer' | 'failed'
|
|
345
|
+
detail?: string
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* default-runtime <runtime> (--peer <p> | --all) — flip the PRIMARY lever
|
|
350
|
+
* (contract Идентичность: primary держит default_runtime, НЕ порядок runtimes[];
|
|
351
|
+
* linus precedent 11.06). This is the routing/wake/first-launch default — the
|
|
352
|
+
* actual fleet-switch moment is a mass flip of exactly this field. The local
|
|
353
|
+
* profile is rewritten through the H1 merge-writer (default_runtime + the legacy
|
|
354
|
+
* in-sync mirror; normalizeRuntimes re-prepends the new default), then the
|
|
355
|
+
* registry is self-healed from the flipped locals (the reindex REPLACE rail —
|
|
356
|
+
* same as `verify --fix`), so routing flips in the same command. REFUSES a peer
|
|
357
|
+
* that does not declare the runtime (add-runtime first) and skips infra peers.
|
|
358
|
+
* Symmetric back: `default-runtime claude --all` reverts the fleet.
|
|
359
|
+
*/
|
|
360
|
+
export async function defaultRuntime(
|
|
361
|
+
runtime: string,
|
|
362
|
+
opts: CliEnvOptions & { peer?: string; all?: boolean },
|
|
363
|
+
): Promise<DefaultRuntimeOutcome[]> {
|
|
364
|
+
const env = opts.env ?? process.env
|
|
365
|
+
if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
|
|
366
|
+
if (isInfraRuntime(runtime)) throw new Error(`"${runtime}" is an infra runtime — not a warm routing default`)
|
|
367
|
+
const index = readPeersIndex({ env })
|
|
368
|
+
const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
|
|
369
|
+
if (targets.length === 0) throw new Error(opts.peer ? `peer "${opts.peer}" is not registered` : 'no targets — pass --peer <p> or --all')
|
|
370
|
+
const out: DefaultRuntimeOutcome[] = []
|
|
371
|
+
for (const p of targets) {
|
|
372
|
+
if (isInfraRuntime(p.runtime)) {
|
|
373
|
+
out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const profile = readPeerProfile(p.cwd)
|
|
378
|
+
if (!profile) {
|
|
379
|
+
out.push({ personality: p.personality, action: 'failed', detail: `no local profile at ${p.cwd}` })
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
if (profile.runtime === runtime) {
|
|
383
|
+
out.push({ personality: p.personality, action: 'already' })
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
if (!profile.runtimes.includes(runtime as Runtime)) {
|
|
387
|
+
out.push({
|
|
388
|
+
personality: p.personality,
|
|
389
|
+
action: 'refused-undeclared-runtime',
|
|
390
|
+
detail: `declares [${profile.runtimes.join(', ')}] — run \`iapeer add-runtime ${runtime} --peer ${p.personality}\` first`,
|
|
391
|
+
})
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
writePeerProfileAtomic(p.cwd, { ...profile, runtime: runtime as Runtime })
|
|
395
|
+
out.push({ personality: p.personality, action: 'flipped' })
|
|
396
|
+
} catch (e) {
|
|
397
|
+
out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Self-heal the registry from the flipped locals so routing flips in the same
|
|
401
|
+
// command (REPLACE projection — the verify --fix rail).
|
|
402
|
+
if (out.some(o => o.action === 'flipped')) await reindexFromLocals({ env })
|
|
403
|
+
return out
|
|
404
|
+
}
|
|
405
|
+
|
|
275
406
|
export interface NewPeerOutcome {
|
|
276
407
|
personality: string
|
|
277
408
|
runtime: string
|
|
@@ -653,6 +784,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
653
784
|
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
654
785
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
655
786
|
new <peer> [runtime] UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)
|
|
787
|
+
add-runtime <runtime> (--peer <p> | --all) add an agentic runtime to existing peer(s): runtimes-merge + scaffold + codex birth chain (pre-trust, native-memory, memory provision, MCP). default_runtime untouched
|
|
788
|
+
default-runtime <runtime> (--peer <p> | --all) flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command
|
|
656
789
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
657
790
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
658
791
|
compact <peer> [runtime] compact the peer's context (/compact)
|
|
@@ -1036,6 +1169,27 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1036
1169
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
1037
1170
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
1038
1171
|
}
|
|
1172
|
+
case 'add-runtime': {
|
|
1173
|
+
// Fleet-switch enabler (codex-parity audit): add an agentic runtime to
|
|
1174
|
+
// existing peer(s) — full codex birth chain per target, idempotent.
|
|
1175
|
+
const rt = positionals[0]
|
|
1176
|
+
if (!rt) return usage(errOut)
|
|
1177
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
1178
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
1179
|
+
const outcomes = await addRuntime(rt, { peer: peerName, all: flags.all === true, env })
|
|
1180
|
+
for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
1181
|
+
return outcomes.some(o => o.action === 'failed') ? 1 : 0
|
|
1182
|
+
}
|
|
1183
|
+
case 'default-runtime': {
|
|
1184
|
+
// The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
|
|
1185
|
+
const rt = positionals[0]
|
|
1186
|
+
if (!rt) return usage(errOut)
|
|
1187
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
1188
|
+
if (flags.all !== true && !peerName) return usage(errOut)
|
|
1189
|
+
const outcomes = await defaultRuntime(rt, { peer: peerName, all: flags.all === true, env })
|
|
1190
|
+
for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
|
|
1191
|
+
return outcomes.some(o => o.action === 'failed') ? 1 : 0
|
|
1192
|
+
}
|
|
1039
1193
|
case 'new': {
|
|
1040
1194
|
// UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
|
|
1041
1195
|
// §new): the emergency lever for a hung/dead session, bypasses the peer.
|