@agfpd/iapeer 0.2.49 → 0.2.50

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.49",
3
+ "version": "0.2.50",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,10 +7,11 @@ 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 { addRuntime, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
10
+ import { addRuntime, compactPeer, 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'
14
+ import { ok } from '../core/errors.ts'
14
15
 
15
16
  let root: string
16
17
  let laDir: string
@@ -452,6 +453,58 @@ describe('new (unconditional fresh restart)', () => {
452
453
  })
453
454
  })
454
455
 
456
+ // ─── compact-resume — control addresses the dialogue, not just a live session ──
457
+
458
+ describe('compact (resume sleeping dialogue)', () => {
459
+ test('clean idle-reaped asleep peer → explicit resume wake, then /compact control', async () => {
460
+ await register('sleepy', 'codex')
461
+ const e = env()
462
+ const cfg = loadLifecycleConfig(e)
463
+ setIdleReaped(cfg, 'codex-sleepy')
464
+ const wakeCalls: unknown[] = []
465
+ const controlCalls: unknown[] = []
466
+ const o = await compactPeer('sleepy', 'codex', {
467
+ env: e,
468
+ wakeFn: async args => {
469
+ wakeCalls.push(args)
470
+ return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
471
+ },
472
+ controlFn: (personality, runtime, command) => {
473
+ controlCalls.push({ personality, runtime, command })
474
+ return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
475
+ },
476
+ })
477
+ expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
478
+ expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
479
+ expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
480
+ expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
481
+ })
482
+
483
+ test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
484
+ await register('freshy', 'codex')
485
+ let woke = false
486
+ const o = await compactPeer('freshy', 'codex', {
487
+ env: env(),
488
+ wakeFn: async () => ((woke = true), { status: 'READY', woke: true }),
489
+ })
490
+ expect(o.action).toBe('nothing-to-compact')
491
+ expect(o.reason).toContain('nothing to compact')
492
+ expect(woke).toBe(false)
493
+ })
494
+
495
+ test('idle marker but no transcript/session to resume → nothing-to-compact (not fresh)', async () => {
496
+ await register('empty', 'codex')
497
+ const e = env()
498
+ setIdleReaped(loadLifecycleConfig(e), 'codex-empty')
499
+ const o = await compactPeer('empty', 'codex', {
500
+ env: e,
501
+ wakeFn: async args => ({ status: 'FAILED', woke: false, runtime: args.runtime, reason: 'no codex session to resume' }),
502
+ })
503
+ expect(o.action).toBe('nothing-to-compact')
504
+ expect(o.reason).toContain('nothing to compact')
505
+ })
506
+ })
507
+
455
508
  // ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
456
509
 
457
510
  describe('add-runtime / default-runtime (fleet-switch levers)', () => {
package/src/cli/index.ts CHANGED
@@ -31,10 +31,12 @@ import {
31
31
  clearNewEager,
32
32
  clearStopped,
33
33
  folderLaunch,
34
+ hasIdleReaped,
34
35
  isLaunchdManaged,
35
36
  isEphemeralPeer,
36
37
  isStopped,
37
38
  killSession,
39
+ lastActiveRuntime,
38
40
  loadLifecycleConfig,
39
41
  purgeIdentityState,
40
42
  removeSessionState,
@@ -503,6 +505,95 @@ export async function newPeer(
503
505
  return { personality, runtime: rt, action: 'fresh' }
504
506
  }
505
507
 
508
+ export interface CompactPeerOutcome {
509
+ personality: string
510
+ runtime: string
511
+ action: 'compacted' | 'nothing-to-compact' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
512
+ woke?: boolean
513
+ reason?: string
514
+ }
515
+
516
+ /**
517
+ * compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
518
+ * session. A live target receives the in-session `/compact` control as before. A
519
+ * cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
520
+ * A crashed / never-run / non-resumable target returns an honest "nothing to compact"
521
+ * instead of starting a fresh empty session and compacting that.
522
+ */
523
+ export async function compactPeer(
524
+ personality: string,
525
+ runtime: string | undefined,
526
+ opts: CliEnvOptions & {
527
+ wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
528
+ controlFn?: typeof routeControl
529
+ } = {},
530
+ ): Promise<CompactPeerOutcome> {
531
+ const env = opts.env ?? process.env
532
+ const cfg = loadLifecycleConfig(env)
533
+ const peer = findPeer(readPeersIndex({ env }), personality)
534
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
535
+ if (isForeignLaunchd(personality, env)) {
536
+ return {
537
+ personality,
538
+ runtime: peer.runtime,
539
+ action: 'refused-foreign-launchd',
540
+ reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not control it`,
541
+ }
542
+ }
543
+ if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
544
+ if (runtime && peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
545
+ return {
546
+ personality,
547
+ runtime,
548
+ action: 'refused-undeclared-runtime',
549
+ reason: `peer "${personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
550
+ }
551
+ }
552
+
553
+ const control = opts.controlFn ?? routeControl
554
+ const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
555
+ if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
556
+ const r = control(personality, runtime, { name: 'compact' })
557
+ if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
558
+ return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
559
+ }
560
+
561
+ const rt: Runtime = (runtime as Runtime | undefined) ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
562
+ if (!peer.runtimes.includes(rt)) {
563
+ return {
564
+ personality,
565
+ runtime: rt,
566
+ action: 'refused-undeclared-runtime',
567
+ reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
568
+ }
569
+ }
570
+ if (isInfraRuntime(rt)) {
571
+ return {
572
+ personality,
573
+ runtime: rt,
574
+ action: 'refused-infra',
575
+ reason: `infra runtime "${rt}" is launchd-held and has no compactable TUI dialogue`,
576
+ }
577
+ }
578
+ const identity = buildProcessAddress(rt, personality)
579
+ if (!hasIdleReaped(cfg, identity)) {
580
+ return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
581
+ }
582
+ const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
583
+ const w = await wake({ personality, runtime: rt, task: '', resume: true })
584
+ if (w.status !== 'READY') {
585
+ const reason = w.reason ?? 'wake failed'
586
+ if (/no .*session to resume|no transcript to resume|nothing to resume/i.test(reason)) {
587
+ return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
588
+ }
589
+ return { personality, runtime: rt, action: 'failed', reason }
590
+ }
591
+ clearIdleReaped(cfg, identity)
592
+ const r = control(personality, rt, { name: 'compact' })
593
+ if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
594
+ return { personality, runtime: rt, action: 'compacted', woke: true }
595
+ }
596
+
506
597
  // ─────────────────────────────────────────────────────────────────────────────
507
598
  // remove — delete a peer's record from the registry through the LOCKED writer
508
599
  // (registry.removePeer). Direct edits of peers-profiles.json are refused at
@@ -804,7 +895,7 @@ const USAGE = `usage: iapeer <verb> [args]
804
895
  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
805
896
  attach <peer> [runtime] ensure-live + resume, then tmux attach
806
897
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
807
- compact <peer> [runtime] compact the peer's context (/compact)
898
+ compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
808
899
  self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
809
900
  self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
810
901
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
@@ -1521,10 +1612,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1521
1612
  killSession(sock, identity)
1522
1613
  return 0
1523
1614
  }
1524
- case 'interrupt':
1525
- case 'compact': {
1615
+ case 'interrupt': {
1526
1616
  // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
1527
- // turn (Escape) / compact context. UNCONDITIONAL — acts on the live session.
1617
+ // turn (Escape). UNCONDITIONAL — acts on the live session.
1528
1618
  if (!positionals[0]) return usage(errOut)
1529
1619
  const r = routeControl(positionals[0], positionals[1], { name: verb })
1530
1620
  if (!r.ok) {
@@ -1534,6 +1624,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1534
1624
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
1535
1625
  return 0
1536
1626
  }
1627
+ case 'compact': {
1628
+ // Dialogue control (UX directive 12.06): if the session is merely asleep
1629
+ // after a clean idle-reap, resume the same dialogue and compact it; if there
1630
+ // is no resumable dialogue, fail honestly instead of compacting a fresh one.
1631
+ if (!positionals[0]) return usage(errOut)
1632
+ const o = await compactPeer(positionals[0], positionals[1], { env })
1633
+ if (o.action === 'compacted') {
1634
+ out(`compact → ${o.personality} (${o.runtime})${o.woke ? ' after resume' : ''}\n`)
1635
+ return 0
1636
+ }
1637
+ errOut(`compact: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1638
+ return 1
1639
+ }
1537
1640
  case 'connect': {
1538
1641
  // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
1539
1642
  // `connect telegram <peer> [--token <t>]`. The human owes only the token;