@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 +1 -1
- package/src/cli/cli.test.ts +54 -1
- package/src/cli/index.ts +107 -4
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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;
|