@agfpd/iapeer 0.2.44 → 0.2.45

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.44",
3
+ "version": "0.2.45",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,61 @@ 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
+ // idempotent
480
+ expect((await addRuntime('codex', { peer: 'aud1', env: e }))[0].action).toBe('already')
481
+ })
482
+
483
+ test('add-runtime refuses an infra runtime as the argument', async () => {
484
+ await register('x1')
485
+ await expect(addRuntime('telegram', { peer: 'x1', env: env() })).rejects.toThrow('infra runtime')
486
+ })
487
+
488
+ test('default-runtime flips the primary (profile + registry in one command); refuses undeclared; symmetric back', async () => {
489
+ const e = env()
490
+ const cwd = join(root, 'peers', 'aud2')
491
+ mkdirSync(cwd, { recursive: true })
492
+ await upsertPeer({ personality: 'aud2', runtime: 'claude', cwd, intelligence: 'artificial' }, { rootDir: root })
493
+ const { initPeer } = await import('../init/index.ts')
494
+ await initPeer({ cwd, runtime: 'claude', env: e })
495
+ // undeclared codex → explicit refusal with the add-runtime hint
496
+ const refused = await defaultRuntime('codex', { peer: 'aud2', env: e })
497
+ expect(refused[0].action).toBe('refused-undeclared-runtime')
498
+ expect(refused[0].detail).toContain('add-runtime')
499
+ // declare codex, then flip
500
+ await addRuntime('codex', { peer: 'aud2', env: e })
501
+ expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
502
+ const { readPeerProfile } = await import('../identity/index.ts')
503
+ expect(readPeerProfile(cwd)!.runtime).toBe('codex')
504
+ expect(readPeerProfile(cwd)!.runtimes[0]).toBe('codex') // normalizeRuntimes prepends the default
505
+ expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('codex') // registry healed same-command
506
+ // idempotent + symmetric revert
507
+ expect((await defaultRuntime('codex', { peer: 'aud2', env: e }))[0].action).toBe('already')
508
+ expect((await defaultRuntime('claude', { peer: 'aud2', env: e }))[0].action).toBe('flipped')
509
+ expect(findPeer(readPeersIndex({ env: e }), 'aud2')!.runtime).toBe('claude')
510
+ })
511
+ })
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,123 @@ 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
+ const { initPeer } = await import('../init/index.ts')
307
+ const out: AddRuntimeOutcome[] = []
308
+ for (const p of targets) {
309
+ if (isInfraRuntime(p.runtime)) {
310
+ out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
311
+ continue
312
+ }
313
+ if (p.runtimes.includes(runtime as Runtime)) {
314
+ out.push({ personality: p.personality, action: 'already' })
315
+ continue
316
+ }
317
+ try {
318
+ const warns: string[] = []
319
+ await initPeer({ cwd: p.cwd, runtime: runtime as Runtime, env, warn: m => warns.push(m) })
320
+ out.push({ personality: p.personality, action: 'added', detail: warns.length ? warns.join(' | ') : undefined })
321
+ } catch (e) {
322
+ out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
323
+ }
324
+ }
325
+ return out
326
+ }
327
+
328
+ export interface DefaultRuntimeOutcome {
329
+ personality: string
330
+ action: 'flipped' | 'already' | 'refused-undeclared-runtime' | 'skipped-infra-peer' | 'failed'
331
+ detail?: string
332
+ }
333
+
334
+ /**
335
+ * default-runtime <runtime> (--peer <p> | --all) — flip the PRIMARY lever
336
+ * (contract Идентичность: primary держит default_runtime, НЕ порядок runtimes[];
337
+ * linus precedent 11.06). This is the routing/wake/first-launch default — the
338
+ * actual fleet-switch moment is a mass flip of exactly this field. The local
339
+ * profile is rewritten through the H1 merge-writer (default_runtime + the legacy
340
+ * in-sync mirror; normalizeRuntimes re-prepends the new default), then the
341
+ * registry is self-healed from the flipped locals (the reindex REPLACE rail —
342
+ * same as `verify --fix`), so routing flips in the same command. REFUSES a peer
343
+ * that does not declare the runtime (add-runtime first) and skips infra peers.
344
+ * Symmetric back: `default-runtime claude --all` reverts the fleet.
345
+ */
346
+ export async function defaultRuntime(
347
+ runtime: string,
348
+ opts: CliEnvOptions & { peer?: string; all?: boolean },
349
+ ): Promise<DefaultRuntimeOutcome[]> {
350
+ const env = opts.env ?? process.env
351
+ if (!isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
352
+ if (isInfraRuntime(runtime)) throw new Error(`"${runtime}" is an infra runtime — not a warm routing default`)
353
+ const index = readPeersIndex({ env })
354
+ const targets = opts.all === true ? index.peers : index.peers.filter(p => p.personality === opts.peer)
355
+ if (targets.length === 0) throw new Error(opts.peer ? `peer "${opts.peer}" is not registered` : 'no targets — pass --peer <p> or --all')
356
+ const out: DefaultRuntimeOutcome[] = []
357
+ for (const p of targets) {
358
+ if (isInfraRuntime(p.runtime)) {
359
+ out.push({ personality: p.personality, action: 'skipped-infra-peer', detail: `default runtime "${p.runtime}" is infra` })
360
+ continue
361
+ }
362
+ try {
363
+ const profile = readPeerProfile(p.cwd)
364
+ if (!profile) {
365
+ out.push({ personality: p.personality, action: 'failed', detail: `no local profile at ${p.cwd}` })
366
+ continue
367
+ }
368
+ if (profile.runtime === runtime) {
369
+ out.push({ personality: p.personality, action: 'already' })
370
+ continue
371
+ }
372
+ if (!profile.runtimes.includes(runtime as Runtime)) {
373
+ out.push({
374
+ personality: p.personality,
375
+ action: 'refused-undeclared-runtime',
376
+ detail: `declares [${profile.runtimes.join(', ')}] — run \`iapeer add-runtime ${runtime} --peer ${p.personality}\` first`,
377
+ })
378
+ continue
379
+ }
380
+ writePeerProfileAtomic(p.cwd, { ...profile, runtime: runtime as Runtime })
381
+ out.push({ personality: p.personality, action: 'flipped' })
382
+ } catch (e) {
383
+ out.push({ personality: p.personality, action: 'failed', detail: e instanceof Error ? e.message : String(e) })
384
+ }
385
+ }
386
+ // Self-heal the registry from the flipped locals so routing flips in the same
387
+ // command (REPLACE projection — the verify --fix rail).
388
+ if (out.some(o => o.action === 'flipped')) await reindexFromLocals({ env })
389
+ return out
390
+ }
391
+
275
392
  export interface NewPeerOutcome {
276
393
  personality: string
277
394
  runtime: string
@@ -653,6 +770,8 @@ const USAGE = `usage: iapeer <verb> [args]
653
770
  connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
654
771
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
655
772
  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)
773
+ 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
774
+ 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
775
  attach <peer> [runtime] ensure-live + resume, then tmux attach
657
776
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
658
777
  compact <peer> [runtime] compact the peer's context (/compact)
@@ -1036,6 +1155,27 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1036
1155
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1037
1156
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
1038
1157
  }
1158
+ case 'add-runtime': {
1159
+ // Fleet-switch enabler (codex-parity audit): add an agentic runtime to
1160
+ // existing peer(s) — full codex birth chain per target, idempotent.
1161
+ const rt = positionals[0]
1162
+ if (!rt) return usage(errOut)
1163
+ const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
1164
+ if (flags.all !== true && !peerName) return usage(errOut)
1165
+ const outcomes = await addRuntime(rt, { peer: peerName, all: flags.all === true, env })
1166
+ for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
1167
+ return outcomes.some(o => o.action === 'failed') ? 1 : 0
1168
+ }
1169
+ case 'default-runtime': {
1170
+ // The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
1171
+ const rt = positionals[0]
1172
+ if (!rt) return usage(errOut)
1173
+ const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
1174
+ if (flags.all !== true && !peerName) return usage(errOut)
1175
+ const outcomes = await defaultRuntime(rt, { peer: peerName, all: flags.all === true, env })
1176
+ for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
1177
+ return outcomes.some(o => o.action === 'failed') ? 1 : 0
1178
+ }
1039
1179
  case 'new': {
1040
1180
  // UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
1041
1181
  // §new): the emergency lever for a hung/dead session, bypasses the peer.