@agfpd/iapeer 0.4.58 → 0.4.60

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.4.58",
3
+ "version": "0.4.60",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -39,6 +39,7 @@ import {
39
39
  addTopic,
40
40
  loadLifecycleConfig,
41
41
  processEagerRelaunches,
42
+ processOrphanEagerMarks,
42
43
  readTopic,
43
44
  resolveWakeRuntime,
44
45
  setEphemeralArmed,
@@ -373,6 +374,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
373
374
  tick: async () => {
374
375
  const outcomes = superviseTick(cfg, { env })
375
376
  await processEagerRelaunches(cfg, outcomes, { env })
377
+ // Orphaned-eager fallback: a .new-eager whose session died WITHOUT a .session
378
+ // on disk is invisible to superviseTick — relaunch it fresh here (H4-guarded;
379
+ // live incident 03.07: a self-fresh peer lay down until the owner attached).
380
+ await processOrphanEagerMarks(cfg, { env })
376
381
  // M3 drain scan — ONE mechanism for the whole serial-queue loop: feeds the
377
382
  // next task right after a reaped-ephemeral (same tick), drains on daemon
378
383
  // start (durable queue), and RETRIES a failed wake (the item stayed at the
@@ -21,7 +21,11 @@
21
21
  // Active 253-261) + the keys the boot loop sends (382-400),
22
22
  // newestCodexSessionMtime + codexSessionCwd (200-234), and
23
23
  // answerPermissionDialog's codex branch (302-306: Down then Enter) over
24
- // dialogs.ts codexApprovalActive (39-45).
24
+ // dialogs.ts codexApprovalActive (39-45). NB the permission autopilot was
25
+ // NOT ported (no answerPermissionDialog in this codebase): the permission
26
+ // class is closed STRUCTURALLY instead — every peer runs
27
+ // --dangerously-bypass-approvals-and-sandbox (buildArgs below) and init
28
+ // bakes default_tools_approval_mode="approve", so the dialog never shows.
25
29
  //
26
30
  // The doctrine -c value is the bare `model_instructions_file=<f>` token: the
27
31
  // TOML value-side quotes in codex-start.sh:224 exist only because that string is
@@ -622,9 +622,26 @@ export function resolveWakeMode(
622
622
  resolveResume: (cwd: string) => { ok: boolean; ref?: string; reason?: string },
623
623
  incomingTopic?: string,
624
624
  ): WakeMode {
625
- // 1. folder-launch → always fresh.
626
- if (argsResume === false) return { resume: false, cause: 'folder-launch' }
627
- // 2. attach → always resume, fail-loud if nothing to resume.
625
+ // 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
626
+ // promise ("next bring-up is FRESH") is fulfilled by this launch; leaving it would
627
+ // leak an eager relaunch into a LATER, unrelated death.
628
+ if (argsResume === false) {
629
+ clearNewEager(cfg, identity)
630
+ return { resume: false, cause: 'folder-launch' }
631
+ }
632
+ // 1.5 — a standing .new-eager OUTRANKS EVERYTHING below, including attach-resume
633
+ // (live incident 03.07: a peer self-freshed, its eager relaunch never fired — the
634
+ // dead session carried no .session so the tick never saw it — and the owner's
635
+ // `iapeer list`→Enter attach then RESUMED the old context, defeating the self-fresh
636
+ // entirely). The mark is the owner's explicit "come up FRESH next"; ANY wake that
637
+ // finds it must honor it. Consume-on-fire + clear .idle-reaped (mirror .fresh-next
638
+ // hygiene: a stale clean-park marker must not make a later crash read as resume).
639
+ if (hasNewEager(cfg, identity)) {
640
+ clearNewEager(cfg, identity)
641
+ clearIdleReaped(cfg, identity)
642
+ return { resume: false, cause: 'self-fresh-eager' }
643
+ }
644
+ // 2. attach → resume, fail-loud if nothing to resume.
628
645
  if (argsResume === true) {
629
646
  const r = resolveResume(cwd)
630
647
  if (!r.ok) return { resume: false, failReason: r.reason ?? 'resume requested but nothing to resume', cause: 'attach-nothing-to-resume' }
@@ -1523,6 +1540,57 @@ export async function processEagerRelaunches(
1523
1540
  return results
1524
1541
  }
1525
1542
 
1543
+ /**
1544
+ * ORPHANED eager-mark fallback (live incident 03.07, iapeer-memory): the standard eager
1545
+ * path runs off superviseTick's `.session` walk — a peer whose session died WITHOUT a
1546
+ * `.session` on disk (an unsupervised live session: a lost/never-written state file —
1547
+ * the code even warns "live but UNSUPERVISED" on a state-write failure) is INVISIBLE to
1548
+ * the tick, so its `.new-eager` was never consumed and the peer lay DOWN until an
1549
+ * external stimulus (the owner's attach — which then also resumed the old context).
1550
+ * This scan makes the mark a DURABLE contract independent of `.session`: every daemon
1551
+ * tick, any `.new-eager` whose identity has NO live session AND NO `.session` (the
1552
+ * standard path would have handled that) is relaunched fresh here. Guards: H4
1553
+ * (launchd-managed → never touched), a LIVE session (self-fresh mark→kill window, or a
1554
+ * concurrent wake that already brought it up — the mark stays; the standard path or the
1555
+ * next tick consumes it), and the wake.lock inside wakeOrSpawn serializes against any
1556
+ * concurrent message-wake (which itself now consumes the mark via resolveWakeMode 1.5).
1557
+ */
1558
+ export async function processOrphanEagerMarks(
1559
+ cfg: LifecycleConfig,
1560
+ deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
1561
+ ): Promise<WakeResult[]> {
1562
+ const env = deps.env ?? process.env
1563
+ const wake = deps.wakeFn ?? wakeOrSpawn
1564
+ let entries: string[] = []
1565
+ try {
1566
+ entries = readdirSync(cfg.stateDir).filter(f => f.endsWith('.new-eager'))
1567
+ } catch {
1568
+ return [] // no state dir → no marks
1569
+ }
1570
+ const results: WakeResult[] = []
1571
+ for (const f of entries) {
1572
+ const identity = f.slice(0, -'.new-eager'.length)
1573
+ const addr = parseSessionName(identity)
1574
+ if (!addr) continue
1575
+ if (isLaunchdManaged(addr.personality, env)) continue // H4 — the daemon never touches launchd-owned peers
1576
+ if (existsSync(sessionStatePath(cfg, identity))) continue // standard eager path owns this death
1577
+ const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
1578
+ if (sessionAlive(sock, identity, env)) continue // mark→kill window or already re-launched — leave the mark
1579
+ appendLifecycleEvent(
1580
+ cfg.eventLogDir,
1581
+ { ev: 'supervise', identity, action: 'eager-orphan-fresh', reason: 'orphaned .new-eager (no .session, session dead)', outcome: 'eager-fresh' },
1582
+ { env },
1583
+ )
1584
+ clearNewEager(cfg, identity) // consume — the relaunch below is fresh by construction
1585
+ try {
1586
+ results.push(await wake({ personality: addr.personality, runtime: addr.runtime, task: '', resume: false }, { cfg, env }))
1587
+ } catch (e) {
1588
+ results.push({ status: 'FAILED', woke: false, reason: e instanceof Error ? e.message : String(e) })
1589
+ }
1590
+ }
1591
+ return results
1592
+ }
1593
+
1526
1594
  // ─────────────────────────────────────────────────────────────────────────────
1527
1595
  // wake_policy:"ephemeral" M3 — the serial-queue DRAIN. Deliveries to an ephemeral
1528
1596
  // target are always ENQUEUED (transport's injected ephemeral seam → queue.ts);
@@ -95,6 +95,10 @@ const NAG_STUCK_MS = 10_000
95
95
  // so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
96
96
  // current model; live-forward delivers the rest once the gate lifts.
97
97
  const MODEL_STABLE_BUDGET_MS = 500
98
+ /** How long firstAttach waits for the child's SIGWINCH redraw wave to ARRIVE after the attach
99
+ * resize (first bytes; the В25-capped stability drain follows). Signal delivery + a full TUI
100
+ * redraw take tens of ms; a child that answers SIGWINCH with nothing just costs this timeout. */
101
+ const SIGWINCH_REDRAW_WAIT_MS = 300
98
102
 
99
103
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
100
104
  interface PtyChild {
@@ -422,36 +426,43 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
422
426
  }
423
427
  writeGeometry(runDir, session, c, r) // record current child geometry → sidecar for warm-deliver / ready-gate readers
424
428
  }
425
- // FIRST attach for a client. Order (codex review) for a clean, coloured, correctly-sized snapshot:
429
+ // FIRST attach for a client. Order for a clean, coloured, correctly-sized snapshot:
426
430
  // 1. DRAIN to stability — pending child bytes were generated under the OLD geometry; parse them BEFORE a
427
431
  // model resize (else they rewrap wrong), and capture bytes that land while the client is gated.
428
- // 2. MODEL resize to the client geometry IF it differs — serialize() round-trips only at matching dims
429
- // (xterm.js #3093), so the model must be the client's size before we serialize.
430
- // 3. SNAPSHOT — serialize + send + lift gate, SYNCHRONOUS (no await), so no live byte interleaves.
431
- // 4. CHILD resize IF it differs — an honest one-SIGWINCH window-resize (like native). NOT a nudge, NOT a
432
- // forced repaint: serialize already recreated screen+cursor, so claude's live bytes continue from the
433
- // matching grid. A same-size reattach touches the child ZERO times → pure snapshot, no re-render.
432
+ // 2. MODEL + CHILD resize to the client geometry IF it differs — BOTH, BEFORE the snapshot. serialize()
433
+ // round-trips only at matching dims (xterm.js #3093); and the child's SIGWINCH must fire NOW so its
434
+ // full redraw is INGESTED BY THE MODEL (client still gated) instead of being live-forwarded on top
435
+ // of an already-sent snapshot. The prior order (child resized AFTER the snapshot) was the attach-dup
436
+ // root on a BUSY session (live incident, Артур 03.07): the post-snapshot SIGWINCH redraw arrived as
437
+ // live bytes whose relative cursor-ups no longer matched the client screen — the whole block painted
438
+ // AGAIN below the snapshot (stacked "Composing…" spinners) — and every busy tick in the window before
439
+ // the SIGWINCH was still OLD-width, wrapping into extra phantom rows. Reproduced: 2× spinner + 2×
440
+ // content copies in the client buffer; with this order: 1× each.
441
+ // 3. WAIT for the redraw wave — the same bounded stabilization as step 1 (В25 cap applies: a saturating
442
+ // stream snapshots at the deadline; ticks after the snapshot are already client-width, so they land
443
+ // cleanly via live-forward).
444
+ // 4. SNAPSHOT — serialize + send + lift gate, SYNCHRONOUS (no await between the last stability check and
445
+ // the send), so no live byte interleaves. A same-size reattach touches the child ZERO times → pure
446
+ // snapshot, no re-render (step 2 is a no-op).
447
+ // Wait for the FIRST child bytes after a resize (the SIGWINCH redraw wave arriving), or the
448
+ // timeout for a child that answers SIGWINCH with nothing (a plain pipe-like program). A bare
449
+ // stability check is NOT enough here: signal delivery + redraw generation take milliseconds,
450
+ // and a busy child's tick cadence has quiet gaps — the stability loop returns in such a gap
451
+ // BEFORE the redraw arrives (proven by the repro byte log: the \x1b[NA redraw came as frame #2,
452
+ // after the snapshot), which re-creates the dup exactly.
453
+ const awaitSeqBump = async (timeoutMs: number): Promise<void> => {
454
+ const seq = writeSeq
455
+ const deadline = Date.now() + timeoutMs
456
+ while (writeSeq === seq && Date.now() < deadline) await Bun.sleep(15)
457
+ }
434
458
  const firstAttach = async (s: { write(b: Buffer): number }, c: number, r: number): Promise<void> => {
435
459
  await awaitModelParsedStable()
436
- const changed = c !== cols || r !== rows
437
- if (changed) {
438
- try {
439
- xterm.resize(c, r) // MODEL only (for the serialize round-trip) — child resized below, after the snapshot
440
- } catch {
441
- /* transient */
442
- }
443
- cols = c
444
- rows = r
445
- }
446
- sendSnapshot(s) // serialize the model → send → lift gate (synchronous, no interleave)
447
- if (changed) {
448
- try {
449
- child.terminal.resize(c, r) // honest one-SIGWINCH resize (post-snapshot, forwarded live), like native
450
- } catch {
451
- /* transient */
452
- }
453
- writeGeometry(runDir, session, c, r) // record the new current child geometry for the warm-deliver readers
460
+ if (c !== cols || r !== rows) {
461
+ applyGeometry(c, r) // model + child together — one honest SIGWINCH, BEFORE the snapshot
462
+ await awaitSeqBump(SIGWINCH_REDRAW_WAIT_MS) // the redraw wave ARRIVES (or the child has none)
463
+ await awaitModelParsedStable() // …then drain it to stability (bounded, В25 cap)
454
464
  }
465
+ sendSnapshot(s) // serialize the post-redraw model → send → lift gate (synchronous, no interleave)
455
466
  }
456
467
  // DETACH = NO RESIZE (emulator invariant). A detached hosted TUI keeps its LAST client geometry — it is
457
468
  // NEVER shrunk back to serve. The old FINDING-2 revert shrank the child into a tiny viewport, making claude