@agfpd/iapeer 0.2.42 → 0.2.43

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.42",
3
+ "version": "0.2.43",
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, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
10
+ import { 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'
@@ -397,3 +397,57 @@ describe('parseArgs (audit #27 — value beginning with --)', () => {
397
397
  expect(parseArgs(['x', '--description=']).flags.description).toBe('')
398
398
  })
399
399
  })
400
+
401
+ // ─── new — the UNCONDITIONAL fresh-restart control (docs/Control-команды §new) ─
402
+
403
+ describe('new (unconditional fresh restart)', () => {
404
+ test('happy path: fresh-slate (un-park + stale markers cleared) → fresh wake with resume:false task:"" → action fresh', async () => {
405
+ await register('alpha')
406
+ const e = env()
407
+ const cfg = loadLifecycleConfig(e)
408
+ // a parked peer with stale markers — /new must override ALL of it
409
+ setStopped(cfg, 'claude-alpha')
410
+ setIdleReaped(cfg, 'claude-alpha')
411
+ const calls: Array<{ personality: string; runtime: string; task: string; resume: boolean }> = []
412
+ const o = await newPeer('alpha', undefined, {
413
+ env: e,
414
+ wakeFn: async args => {
415
+ calls.push(args)
416
+ return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
417
+ },
418
+ })
419
+ expect(o.action).toBe('fresh')
420
+ expect(o.runtime).toBe('claude')
421
+ expect(calls).toEqual([{ personality: 'alpha', runtime: 'claude', task: '', resume: false }])
422
+ expect(isStopped(cfg, 'claude-alpha')).toBe(false) // un-parked: /new outranks the C1 stop
423
+ expect(hasIdleReaped(cfg, 'claude-alpha')).toBe(false) // stale marker cleared
424
+ })
425
+
426
+ test('wake FAILED → action failed with the reason (exit-1 contract for the bot)', async () => {
427
+ await register('beta2')
428
+ const o = await newPeer('beta2', undefined, {
429
+ env: env(),
430
+ wakeFn: async () => ({ status: 'FAILED', woke: false, reason: 'boot hang' }),
431
+ })
432
+ expect(o.action).toBe('failed')
433
+ expect(o.reason).toBe('boot hang')
434
+ })
435
+
436
+ test('refusals are explicit: foreign launchd / infra runtime / undeclared runtime / unknown peer', async () => {
437
+ const e = env()
438
+ // foreign PP plist → fleet guard
439
+ await register('boris')
440
+ writeFileSync(launchdPlistPath('boris', e), '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.boris</string></dict></plist>\n')
441
+ expect((await newPeer('boris', undefined, { env: e })).action).toBe('refused-foreign-launchd')
442
+ // infra runtime → launchd-held, kickstart hint
443
+ await register('tg', 'telegram', 'natural')
444
+ const infra = await newPeer('tg', undefined, { env: e })
445
+ expect(infra.action).toBe('refused-infra')
446
+ expect(infra.reason).toContain('kickstart')
447
+ // undeclared runtime → explicit refusal, no silent launch
448
+ await register('gamma2')
449
+ expect((await newPeer('gamma2', 'codex', { env: e })).action).toBe('refused-undeclared-runtime')
450
+ // unknown peer → throws (same contract as stop/start)
451
+ await expect(newPeer('ghost-nobody', undefined, { env: e })).rejects.toThrow('not registered')
452
+ })
453
+ })
package/src/cli/index.ts CHANGED
@@ -27,6 +27,8 @@ import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../regist
27
27
  import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
28
28
  import {
29
29
  attachPeer,
30
+ clearIdleReaped,
31
+ clearNewEager,
30
32
  clearStopped,
31
33
  folderLaunch,
32
34
  isLaunchdManaged,
@@ -41,6 +43,7 @@ import {
41
43
  setNewEager,
42
44
  setStopped,
43
45
  wakeOrSpawn,
46
+ type WakeResult,
44
47
  } from '../lifecycle/index.ts'
45
48
  import { getAdapter } from '../launch/index.ts'
46
49
  import { isFoundationOwnedPlist, kickstartDaemon, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
@@ -269,6 +272,90 @@ export function startPeer(personality: string, runtime: string | undefined, opts
269
272
  return out
270
273
  }
271
274
 
275
+ export interface NewPeerOutcome {
276
+ personality: string
277
+ runtime: string
278
+ action: 'fresh' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
279
+ reason?: string
280
+ }
281
+
282
+ /**
283
+ * new <peer> [runtime] — the UNCONDITIONAL fresh-restart control command
284
+ * (docs/Control-команды §new; постановка boris 01:32 12.06, санкция Артура).
285
+ *
286
+ * The emergency lever for a HUNG or dead agent session: /alias-new covers only
287
+ * the COOPERATIVE path (a live peer reads the expanded prompt and runs
288
+ * `iapeer self-fresh`); a stuck/raving/dead peer never reads a prompt — this
289
+ * command restarts it MECHANICALLY, bypassing the peer entirely:
290
+ * fresh-slate markers (un-park C1 stop, clear stale .idle-reaped/.new-eager) →
291
+ * canary-clean teardown of any live session (killSession — a deliberate kill,
292
+ * never a death class; session-state dropped so supervise stays silent) →
293
+ * wakeOrSpawn(resume:false, task:'') — fresh BY CONSTRUCTION, the same recipe
294
+ * as the eager relaunch (the C2 initial_prompt seeds the first turn).
295
+ *
296
+ * exit-0 contract (сверка telegram-runtime): success ⟺ the fresh session is UP
297
+ * and READY (verified by the wake's ready gate, not merely scheduled) — for a
298
+ * sleeping, dead AND hung target alike. Duration is a real TUI boot: typically
299
+ * 5–30 s, bounded by cfg.bootDeadlineSecs. Idempotent in effect: each repeat
300
+ * leaves exactly one fresh live session (concurrent calls serialize on the
301
+ * wake lock). REFUSES foreign-launchd peers (fleet guard) and infra runtimes
302
+ * (launchd-held — their restart is `launchctl kickstart`'s domain), like
303
+ * stop/start; an explicitly-passed runtime the peer does not declare is an
304
+ * explicit refusal (never a silent launch of an undeclared runtime).
305
+ */
306
+ export async function newPeer(
307
+ personality: string,
308
+ runtime: string | undefined,
309
+ opts: CliEnvOptions & { wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: false }) => Promise<WakeResult> } = {},
310
+ ): Promise<NewPeerOutcome> {
311
+ const env = opts.env ?? process.env
312
+ const cfg = loadLifecycleConfig(env)
313
+ const peer = findPeer(readPeersIndex({ env }), personality)
314
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
315
+ if (isForeignLaunchd(personality, env)) {
316
+ return {
317
+ personality,
318
+ runtime: peer.runtime,
319
+ action: 'refused-foreign-launchd',
320
+ reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not restart it`,
321
+ }
322
+ }
323
+ if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
324
+ const rt: Runtime = (runtime as Runtime | undefined) ?? peer.runtime
325
+ if (!peer.runtimes.includes(rt)) {
326
+ return {
327
+ personality,
328
+ runtime: rt,
329
+ action: 'refused-undeclared-runtime',
330
+ reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
331
+ }
332
+ }
333
+ if (isInfraRuntime(rt)) {
334
+ return {
335
+ personality,
336
+ runtime: rt,
337
+ action: 'refused-infra',
338
+ reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${launchdLabel(personality)}`,
339
+ }
340
+ }
341
+ const identity = buildProcessAddress(rt, personality)
342
+ const sock = buildSocketPath(rt, personality, cfg.sockDir)
343
+ // Fresh slate: an explicit operator /new outranks a C1 park (it demands a live
344
+ // fresh session NOW) and any stale death/fresh markers (the wake below is fresh
345
+ // by construction — markers must not leak into a LATER wake's decision).
346
+ clearStopped(cfg, identity)
347
+ clearIdleReaped(cfg, identity)
348
+ clearNewEager(cfg, identity)
349
+ killSession(sock, identity) // canary-clean deliberate teardown; no-op when dead
350
+ removeSessionState(cfg, identity) // never a death class — supervise stays silent
351
+ const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
352
+ const r = await wake({ personality, runtime: rt, task: '', resume: false })
353
+ if (r.status !== 'READY') {
354
+ return { personality, runtime: rt, action: 'failed', reason: r.reason ?? 'wake failed' }
355
+ }
356
+ return { personality, runtime: rt, action: 'fresh' }
357
+ }
358
+
272
359
  // ─────────────────────────────────────────────────────────────────────────────
273
360
  // remove — delete a peer's record from the registry through the LOCKED writer
274
361
  // (registry.removePeer). Direct edits of peers-profiles.json are refused at
@@ -565,6 +652,7 @@ const USAGE = `usage: iapeer <verb> [args]
565
652
  <runtime> launch the cwd's peer (ALWAYS fresh)
566
653
  connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
567
654
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
655
+ 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)
568
656
  attach <peer> [runtime] ensure-live + resume, then tmux attach
569
657
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
570
658
  compact <peer> [runtime] compact the peer's context (/compact)
@@ -948,6 +1036,20 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
948
1036
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
949
1037
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
950
1038
  }
1039
+ case 'new': {
1040
+ // UNCONDITIONAL fresh restart (control, system class — docs/Control-команды
1041
+ // §new): the emergency lever for a hung/dead session, bypasses the peer.
1042
+ // Source: operator CLI, or telegram-runtime's clean-/new detect (their bot
1043
+ // shells `iapeer new <peer> <runtime>` — exit 0 ⟺ fresh session up+ready).
1044
+ if (!positionals[0]) return usage(errOut)
1045
+ const o = await newPeer(positionals[0], positionals[1], { env })
1046
+ if (o.action === 'fresh') {
1047
+ out(`new: ${o.runtime}-${o.personality} fresh session up\n`)
1048
+ return 0
1049
+ }
1050
+ errOut(`new: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1051
+ return 1
1052
+ }
951
1053
  case 'start': {
952
1054
  // --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
953
1055
  // incident: bringing a stopped fleet back required a manual loop over ~25
@@ -319,11 +319,16 @@ export const codexAdapter: RuntimeAdapter = {
319
319
  * interrupts the turn — NOT a double. (Codex's own footer says "esc to
320
320
  * interrupt"; one Escape yields "■ Conversation interrupted". The contract's
321
321
  * open "×1/×2?" is resolved to ×1, same as claude.) Session + context intact.
322
- * - compact → null (codex has no '/compact'; context management is its own).
322
+ * - compact → type '/compact' then Enter. SNAPPED LIVE 12.06 (codex-cli
323
+ * 0.138.0): the slash EXISTS now — autocomplete documents it («summarize
324
+ * conversation to prevent hitting the context limit») and a live run
325
+ * yields «• Context compacted». The 0.136-era "codex has no /compact"
326
+ * finding is stale; the mapping is keyboard-identical to claude's.
323
327
  * - anything else → null.
324
328
  */
325
329
  executeControl(command: ControlCommand): ControlPlan | null {
326
330
  if (command.name === 'interrupt') return { sequence: [['Escape']] }
331
+ if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
327
332
  return null
328
333
  },
329
334
  }
@@ -296,9 +296,10 @@ describe('executeControl (Ф-E control commands)', () => {
296
296
  expect(claudeAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
297
297
  expect(claudeAdapter.executeControl({ name: 'bogus' })).toBeNull()
298
298
  })
299
- test('codex: interrupt → [Escape] (×1 baseline; ×2 snapped live); compact → null', () => {
299
+ test('codex: interrupt → [Escape] (×1, snapped live); compact → /compact + Enter (snapped live 12.06, codex-cli 0.138)', () => {
300
300
  expect(codexAdapter.executeControl({ name: 'interrupt' })).toEqual({ sequence: [['Escape']] })
301
- expect(codexAdapter.executeControl({ name: 'compact' })).toBeNull()
301
+ expect(codexAdapter.executeControl({ name: 'compact' })).toEqual({ sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 })
302
+ expect(codexAdapter.executeControl({ name: 'bogus' })).toBeNull()
302
303
  })
303
304
  test('routers (telegram/notifier) refuse all control (no TUI turn)', () => {
304
305
  expect(telegramAdapter.executeControl({ name: 'interrupt' })).toBeNull()