@agfpd/iapeer 0.4.8 → 0.4.10

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.
@@ -26,7 +26,7 @@ import {
26
26
  } from '../core/constants.ts'
27
27
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
- import { capCmdLogs, capPaneLogs, cmdLogDirFor } from '../launch/cmdlog.ts'
29
+ import { capPaneLogs } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
31
  import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
32
32
  import {
@@ -47,8 +47,7 @@ import {
47
47
  type LaunchSpec,
48
48
  } from '../launch/index.ts'
49
49
  import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
50
- import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
51
- import { hostSessionAlive, killPtyHost, ptyHostEnabled } from '../launch/ptyHost.ts'
50
+ import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
52
51
  import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
53
52
 
54
53
  // ─────────────────────────────────────────────────────────────────────────────
@@ -494,18 +493,6 @@ export function purgeIdentityState(cfg: LifecycleConfig, identity: string): stri
494
493
  /* best-effort — a locked/vanished entry must not fail the remove */
495
494
  }
496
495
  }
497
- // tmux command-log dir (0.2.40 observability) — identity-keyed like the state
498
- // markers above, so it must die with the registry record too (the leak class
499
- // noted at the 0.2.40 acceptance: a removed peer left its cmdlog dir forever).
500
- try {
501
- const cmdDir = cmdLogDirFor(cfg.eventLogDir, identity)
502
- if (existsSync(cmdDir)) {
503
- rmSync(cmdDir, { recursive: true, force: true })
504
- removed.push(`tmux-cmdlog/${identity}`)
505
- }
506
- } catch {
507
- /* best-effort */
508
- }
509
496
  return removed
510
497
  }
511
498
 
@@ -724,52 +711,23 @@ export async function withWakeLock<T>(
724
711
  }
725
712
 
726
713
  // ─────────────────────────────────────────────────────────────────────────────
727
- // tmux helpers
714
+ // liveness
728
715
  // ─────────────────────────────────────────────────────────────────────────────
729
716
 
730
- function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err: string } {
731
- const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
732
- return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
733
- }
734
- function sessionAlive(sock: string, identity: string): boolean {
735
- // RUNTIME-STATE host detection (spawn-flip Ф0b), NOT the `.pty-host` marker: a peer with a LIVE
736
- // supervisor session is hosted-and-alive; otherwise fall to tmux has-session. Routing liveness on
737
- // the live supervisor session (not the spawn-intent marker) makes a marker rollback safe on a
738
- // still-running hosted session — rm-marker only changes the NEXT spawn, never the running routing.
739
- // Non-hosted peers: hostSessionAlive is a cheap pid-file miss → identical result to today (tmux).
740
- return hostSessionAlive(identity) || tmux(sock, 'has-session', '-t', identity).ok
741
- }
742
-
743
- /** Death-class tag for a gone session (live case: the WHOLE tmux server died by
744
- * SIGKILL-class and exits.log stayed empty, because the pane-died hook needs a
745
- * living tmux event loop). Two distinguishable classes:
746
- * - `session-gone` — the server on the socket still ANSWERS but the session is not
747
- * there: a pane died inside a living server → the pane-died hook had its chance,
748
- * exits.log should carry the cause.
749
- * - `server-dead` — the server itself is gone: the socket file is missing, or it
750
- * exists but nothing serves it (stale socket — the SIGKILL/OOM class). pane-died
751
- * could never fire; the server-death canary (launch/canary.ts) is the recorder
752
- * for this class (`ev=server-exit` + forensics), this line is the backstop.
753
- * `hosted=true` (pty-supervisor peer) reframes ONLY the absent-socket reason: a hosted peer
754
- * has NO tmux socket, so that branch would otherwise misattribute its death to a tmux server
755
- * that never existed. Its real cause lives in the supervisor exits.log. A socket that EXISTS
756
- * is a genuine tmux server, so `hosted` never overrides the session-gone / stale-socket arm. */
757
- export function classifyGoneSession(sock: string, hosted = false): { death: 'server-dead' | 'session-gone'; reason: string } {
758
- if (!existsSync(sock)) {
759
- // No socket file. For a pty-supervisor-hosted peer this is EXPECTED — it never had a tmux
760
- // socket; its death is the supervisor session ending (cause in the supervisor exits.log
761
- // death-EVENT), NOT a tmux server vanishing. For a tmux peer it IS a dead tmux server.
762
- // The check is gated on the absent socket on purpose: a socket that EXISTS (below) means a
763
- // real tmux server, so `hosted` (pty-enabled-by-default) must not override genuine tmux.
764
- return hosted
765
- ? { death: 'server-dead', reason: 'pty host session ended (supervisor) — cause in exits.log (death-EVENT)' }
766
- : { death: 'server-dead', reason: 'tmux server gone (socket file missing)' }
767
- }
768
- // Ask the SERVER, not the session: any server-level command answering (exit 0)
769
- // proves the server is alive and merely lost this session.
770
- return tmux(sock, 'list-sessions').ok
771
- ? { death: 'session-gone', reason: 'session gone, tmux server alive (exit cause should be in exits.log)' }
772
- : { death: 'server-dead', reason: 'tmux server dead — stale socket (SIGKILL class — OOM only if a matching JetsamEvent report exists; check exits.log ev=server-exit canary record)' }
717
+ function sessionAlive(_sock: string, identity: string): boolean {
718
+ // pty-only: a peer is alive iff its supervisor session is live (a cheap pid-file check). Routing
719
+ // liveness on the live session (not the `.pty-host` spawn-intent marker) keeps a marker rollback safe
720
+ // on a still-running session. (`_sock` retained for call-site compatibility; no tmux sockets exist.)
721
+ return hostSessionAlive(identity)
722
+ }
723
+
724
+ /** Death-class tag for a gone session (pty-only). A gone peer is a supervisor session that ended; its
725
+ * real cause lives in the supervisor exits.log (death-EVENT). There is no tmux server to distinguish
726
+ * `session-gone` (pane died, server alive) from `server-dead` (server gone). This tag is
727
+ * observability-only (lifecycle.log trace) and drives no decision. The `death`/`hosted` shape is kept
728
+ * for the trace schema + call-site compatibility. */
729
+ export function classifyGoneSession(_sock: string, _hosted = false): { death: 'server-dead' | 'session-gone'; reason: string } {
730
+ return { death: 'server-dead', reason: 'pty host session ended (supervisor) — cause in exits.log (death-EVENT)' }
773
731
  }
774
732
 
775
733
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1149,26 +1107,11 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1149
1107
  // Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
1150
1108
  // ─────────────────────────────────────────────────────────────────────────────
1151
1109
 
1152
- export function killSession(sock: string, identity: string): void {
1153
- // Hosted peer (spawn-flip Ф0b): SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and
1154
- // removes the sock/pid/serve-spec. The tmux teardown below is then a harmless no-op (no tmux session
1155
- // on this sock); for a non-hosted peer it is the real reap, byte-identical to today.
1156
- if (hostSessionAlive(identity)) killPtyHost(identity)
1157
- tmux(sock, 'kill-session', '-t', identity)
1158
- const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
1159
- if (!sessions.trim()) {
1160
- // Deliberate server teardown → silence the canary EXPLICITLY first: signal
1161
- // the channel (client exits 0) AND dismiss the sh recorder (canary v2 no
1162
- // longer reads a signaled client alone as deliberate — see canary.ts).
1163
- signalCanaryClean(sock, identity)
1164
- dismissCanary(identity)
1165
- tmux(sock, 'kill-server')
1166
- try {
1167
- rmSync(sock, { force: true })
1168
- } catch {
1169
- /* best-effort */
1170
- }
1171
- }
1110
+ export function killSession(_sock: string, identity: string): void {
1111
+ // pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
1112
+ // sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
1113
+ // call-site compatibility; no tmux sockets exist.)
1114
+ killPtyHost(identity)
1172
1115
  }
1173
1116
 
1174
1117
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1212,11 +1155,6 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1212
1155
  const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
1213
1156
  const isAlive = deps.sessionAlive ?? sessionAlive
1214
1157
  const verbose = superviseLogVerbose(env)
1215
- // tmux command-log volume cap (killer-hunt socket-command layer, launch/cmdlog.ts):
1216
- // a -v server log under a busy TUI grows ~7 KB/s, so each tick tail-keeps any log
1217
- // over the cap. Best-effort by construction; the killer's command is the LAST
1218
- // line of a dying log, so capping a LIVE peer's log never costs the death tail.
1219
- capCmdLogs(cfg.eventLogDir)
1220
1158
  // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1221
1159
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
1222
1160
  // append for a session's whole life — it had NO bound and grew to hundreds of MB
@@ -1283,7 +1221,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1283
1221
  // distinguishable in lifecycle.log: `session-gone` (pane died, server alive →
1284
1222
  // exits.log should have the cause) vs `server-dead` (whole tmux server died →
1285
1223
  // exits.log structurally empty; this line is the only durable trace).
1286
- const gone = classifyGoneSession(sock, ptyHostEnabled(cfg.logDir, s.identity))
1224
+ const gone = classifyGoneSession(sock)
1287
1225
  out.push({ identity: s.identity, action: 'reaped-gone', reason: gone.reason })
1288
1226
  trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
1289
1227
  continue
@@ -1366,22 +1304,8 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1366
1304
  // every ALIVE daemon-owned server — covers a fleet launched by older code
1367
1305
  // within one tick of a deploy, no session restarts. Idempotent (pgrep on
1368
1306
  // the per-identity wait-for channel), best-effort, pure observability.
1369
- // A non-'already' state IS a decision-grade event (not verbose-gated):
1370
- // post-0.2.22 every launch arms a canary, so a retrofit 'spawned' on an
1371
- // alive server means the previous canary VANISHED mid-watch (the death-#4
1372
- // blind spot was exactly this churn being invisible); 'failed' means the
1373
- // server is currently UNWATCHED. At most one line per loss, not per tick.
1374
- // SPAWN-FLIP Ф0b-3: the tmux server-death canary is meaningless for a supervisor-HOSTED session
1375
- // (no tmux server — the retrofit arms a watcher that immediately fires a spurious
1376
- // `server-vanished`). A hosted death is recorded by the supervisor's exits.log (host=supervisor,
1377
- // resilience-b), so skip the canary retrofit for hosted peers (mirror of the observer .pty-host
1378
- // skip — tmux-assuming infra must not run against a hosted session).
1379
- if (!hostSessionAlive(s.identity)) {
1380
- const canaryState = ensureServerCanary({ identity: s.identity, sock, exitLogDir: cfg.eventLogDir, env })
1381
- if (canaryState !== 'already') {
1382
- trace({ identity: s.identity, action: 'canary', state: canaryState, origin: 'retrofit' })
1383
- }
1384
- }
1307
+ // pty-only: no tmux server-death canary — a hosted death is recorded by the supervisor's
1308
+ // exits.log (host=supervisor, resilience-b), classified by classifyGoneSession on the next sweep.
1385
1309
  out.push({ identity: s.identity, action: 'alive' })
1386
1310
  if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
1387
1311
  }
@@ -158,9 +158,11 @@ function normalizePeer(raw: unknown): PeerRecord {
158
158
  )}"`,
159
159
  )
160
160
  }
161
- // default_runtime is the contract field; `runtime` is legacy. Dual-read accepts
162
- // both during the staged migration; when both are present default_runtime WINS
163
- // (the write emits default_runtime + the legacy mirror since Phase 2).
161
+ // default_runtime is the contract field. Phase-3 (registry): foundation no longer WRITES the legacy
162
+ // `runtime` mirror (see writePeersIndexAtomic), but the read-fallback is KEPT as a dormant defensive
163
+ // net — it tolerates a legacy-shaped entry (restored backup / hand-edit) at a 1-token cost. All real
164
+ // readers switched (telegram → default_runtime, notifier → cwd-only); registry is single-writer so
165
+ // new entries are default_runtime-only. Full fallback removal is a future final cleanup.
164
166
  const defaultRuntime = obj.default_runtime ?? obj.runtime
165
167
  if (!isRuntime(defaultRuntime)) {
166
168
  throw new IapError(
@@ -315,15 +317,15 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
315
317
  // Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
316
318
  // intelligenceRaw shadow field from the on-disk shape — the file carries the
317
319
  // legacy vocab the live IAP reads, never the foundation's in-memory normalization.
318
- // Phase-2 write-flip (staged default_runtime migration): the disk shape carries the
319
- // contract field `default_runtime` PLUS the legacy `runtime` as an in-sync mirror,
320
- // so not-yet-dual-read readers keep working. Phase 3 removes the mirror.
320
+ // Phase-3 (registry): the disk shape carries ONLY the contract field `default_runtime` — the legacy
321
+ // `runtime` mirror is removed (all registry readers switched: telegram → default_runtime, notifier →
322
+ // cwd-only, foundation → default_runtime). Local per-peer profiles keep their mirror until the paired
323
+ // telegram writePeerProfile follow-up.
321
324
  const peersForDisk = [...index.peers]
322
325
  .sort((a, b) => a.personality.localeCompare(b.personality))
323
326
  .map(peer => ({
324
327
  personality: peer.personality,
325
328
  default_runtime: peer.runtime,
326
- runtime: peer.runtime,
327
329
  runtimes: peer.runtimes,
328
330
  description: peer.description,
329
331
  intelligence: peer.intelligenceRaw ?? peer.intelligence,
@@ -26,7 +26,7 @@ import {
26
26
  } from './protocol.ts'
27
27
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
28
28
  import { nextBootAction, type BootPredicates } from './boot.ts'
29
- import { modelToPlainText } from '../shadow/render.ts'
29
+ import { modelToPlainText } from './render.ts'
30
30
  import { codexAdapter } from '../launch/adapters/codex.ts'
31
31
  import { claudeAdapter } from '../launch/adapters/claude.ts'
32
32
 
@@ -1,7 +1,7 @@
1
1
  // Supervisor session paths — under a configurable run dir (NOT hardcoded). Prod resolves it to
2
2
  // ~/.iapeer/state/supervisor via the storage layer; tests pass a temp dir, so the daemon's
3
3
  // socket/pid files never touch a real location.
4
- import { renameSync, unlinkSync, writeFileSync } from 'node:fs'
4
+ import { renameSync, writeFileSync } from 'node:fs'
5
5
  import { readFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
  import { pluginStateDir } from '../storage/index.ts'
@@ -57,12 +57,3 @@ export function readGeometry(runDir: string, session: string): { cols: number; r
57
57
  }
58
58
  return null
59
59
  }
60
-
61
- /** Remove the geometry sidecar (shutdown cleanup); best-effort. */
62
- export function clearGeometry(runDir: string, session: string): void {
63
- try {
64
- unlinkSync(geometryPath(runDir, session))
65
- } catch {
66
- /* */
67
- }
68
- }
@@ -1,7 +1,8 @@
1
- // Leaf render helper: @xterm model → plain text. Extracted from shadow/index.ts so consumers
2
- // (the shadow observer, the Ф1 ready-gate flip, and the Block-2 supervisor boot-driver) share ONE
3
- // definition without importing the observer's transport/launch graph. Type-only @xterm import →
4
- // no runtime dependency beyond the Terminal instance the caller already holds; hermetically testable.
1
+ // Leaf render helper: @xterm model → plain text + composer-occupancy verdict. A shared LIVE leaf used
2
+ // by the Ф1 ready-gate flip (launch/readyGateModel) and the supervisor pane model. Type-only @xterm
3
+ // import → no runtime dependency beyond the Terminal instance the caller already holds; hermetically
4
+ // testable. (Formerly src/shadow/render.ts — kept after the tmux→pty cutover retired the shadow
5
+ // burn-in observer; only the observer modules were removed, this leaf survives.)
5
6
  import type { Terminal } from '@xterm/headless'
6
7
 
7
8
  /**
@@ -10,7 +11,7 @@ import type { Terminal } from '@xterm/headless'
10
11
  * whitespace per line is trimmed (capture-pane does the same), cells coalesced left-to-right.
11
12
  *
12
13
  * 0-divergence-validated vs capture-pane on READY screens by the ready-gate burn-in; the supervisor
13
- * boot-driver additionally validates it on BOOT-DIALOG screens (a different screen state the
14
+ * boot-driver additionally validated it on BOOT-DIALOG screens (a different screen state the
14
15
  * ready-gate observation never saw).
15
16
  */
16
17
  export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
@@ -40,11 +41,10 @@ const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
40
41
  /**
41
42
  * PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose first
42
43
  * visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246) visible cell
43
- * after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it by the burn-in. Lives
44
- * in this leaf (type-only @xterm) so the spawn-flip hosted occupancy guard can reuse it WITHOUT
45
- * pulling the @xterm-loading observer graph into the warm-deliver path; the Terminal instance is
46
- * built by the caller (paneLogComposerOccupied dynamic-imports @xterm). Re-exported from
47
- * shadow/index.ts so the observer's existing importers keep resolving.
44
+ * after it. Mirrors the prior tmux capture-pane occupancy check (validated 0-divergence vs it by the
45
+ * burn-in). Lives in this leaf (type-only @xterm) so the warm-deliver hosted occupancy guard can reuse
46
+ * it WITHOUT pulling an @xterm-loading graph into the warm-deliver path; the Terminal instance is built
47
+ * by the caller (paneLogComposerOccupied dynamic-imports @xterm).
48
48
  */
49
49
  export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
50
50
  const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)