@agfpd/iapeer 0.2.93 → 0.2.95

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.93",
3
+ "version": "0.2.95",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/init/index.ts CHANGED
@@ -298,6 +298,12 @@ export function resolvePrimaryRuntime(
298
298
  isInstalled: (rt: Runtime) => boolean = rt => isRuntimeInstalled(rt),
299
299
  ): Runtime {
300
300
  if (explicit) {
301
+ // Infra runtimes (notifier/telegram) are NOT agentic binaries — their presence is
302
+ // the npx runtime package + the launchd plist, not a claude/codex install. The
303
+ // install-check below is agentic-only, so an explicit infra runtime must pass
304
+ // straight through; otherwise re-deploying an infra peer (install-runtime /
305
+ // update-runtime → createPeer → initPeer) wrongly fails "not installed".
306
+ if (isInfraRuntime(explicit)) return explicit
301
307
  if (!isInstalled(explicit)) {
302
308
  throw new IapError(
303
309
  `runtime "${explicit}" is not installed on this host — install it (Claude Code / Codex CLI) or choose an installed runtime`,
@@ -38,6 +38,14 @@ import { join } from 'path'
38
38
  export const CMDLOG_CAP_BYTES = 8 * 1024 * 1024
39
39
  export const CMDLOG_KEEP_BYTES = 1024 * 1024
40
40
 
41
+ /** Pane-log tail-keep cap (capPaneLogs): a per-identity pane-log over CAP is cut to
42
+ * its last KEEP. KEEP_BYTES INVARIANT — must stay ≥ the pane-log reader's seed
43
+ * window (readyGateModel SEED_BYTES = 4 MiB, the tail fed into the headless xterm
44
+ * for composer-occupancy / viewport detection); capping below it would starve that
45
+ * reader of the tail it reconstructs the live screen from. 8 MiB = 2× the seed. */
46
+ export const PANELOG_CAP_BYTES = 32 * 1024 * 1024
47
+ export const PANELOG_KEEP_BYTES = 8 * 1024 * 1024
48
+
41
49
  const CMDLOG_SUBDIR = 'tmux-cmdlog'
42
50
 
43
51
  /** The per-identity command-log dir: <eventLogDir>/tmux-cmdlog/<identity>. The
@@ -78,12 +86,34 @@ export function hardenCmdLogDir(dir: string): void {
78
86
  }
79
87
  }
80
88
 
89
+ /** Tail-keep ONE file: if it exceeds capBytes, rewrite it as its last keepBytes.
90
+ * Safe against a live O_APPEND writer (tmux `cat >>`, the supervisor append-fd):
91
+ * the next write lands at the new EOF (proven by live truncation test). Lines
92
+ * appended between the tail-read and the rewrite are lost — acceptable mid-life
93
+ * noise. Returns true iff the file was capped. Best-effort: swallows FS errors. */
94
+ function tailKeepFile(path: string, capBytes: number, keepBytes: number): boolean {
95
+ try {
96
+ if (statSync(path).size <= capBytes) return false
97
+ const fd = openSync(path, 'r')
98
+ let tail: Buffer
99
+ try {
100
+ const size = fstatSync(fd).size
101
+ const start = Math.max(0, size - keepBytes)
102
+ tail = Buffer.alloc(Math.min(keepBytes, size))
103
+ readSync(fd, tail, 0, tail.length, start)
104
+ } finally {
105
+ closeSync(fd)
106
+ }
107
+ writeFileSync(path, tail, { mode: 0o600 })
108
+ return true
109
+ } catch {
110
+ return false // per-file best-effort
111
+ }
112
+ }
113
+
81
114
  /** Tail-keep cap over every command log under <eventLogDir>/tmux-cmdlog/<identity>.
82
- * A file over capBytes is rewritten as its last keepBytes. Safe against the
83
- * live writer: tmux logs with O_APPEND, so the server's next write lands at
84
- * the new EOF (proven by live truncation test). Lines appended between the
85
- * tail-read and the rewrite are lost — acceptable mid-life noise; the death
86
- * tail is never here because the peer is alive while being capped. */
115
+ * A file over capBytes is rewritten as its last keepBytes. The death tail is never
116
+ * here because the peer is alive while being capped. */
87
117
  export function capCmdLogs(
88
118
  eventLogDir: string,
89
119
  capBytes: number = CMDLOG_CAP_BYTES,
@@ -107,24 +137,36 @@ export function capCmdLogs(
107
137
  for (const name of files) {
108
138
  if (!name.endsWith('.log')) continue
109
139
  const path = join(dir, name)
110
- try {
111
- if (statSync(path).size <= capBytes) continue
112
- const fd = openSync(path, 'r')
113
- let tail: Buffer
114
- try {
115
- const size = fstatSync(fd).size
116
- const start = Math.max(0, size - keepBytes)
117
- tail = Buffer.alloc(Math.min(keepBytes, size))
118
- readSync(fd, tail, 0, tail.length, start)
119
- } finally {
120
- closeSync(fd)
121
- }
122
- writeFileSync(path, tail, { mode: 0o600 })
123
- capped.push(path)
124
- } catch {
125
- /* per-file best-effort */
126
- }
140
+ if (tailKeepFile(path, capBytes, keepBytes)) capped.push(path)
127
141
  }
128
142
  }
129
143
  return capped
130
144
  }
145
+
146
+ /** Tail-keep cap over the per-identity PANE-LOGS (<logDir>/<identity>.log) — the RAW
147
+ * TUI byte stream that pipe-pane (tmux) / the supervisor append for a session's whole
148
+ * life. Unlike the lifecycle/delivery logs (their own size-rotation under logs/iapeer),
149
+ * the pane-log had NO bound and grew to hundreds of MB per warm peer on an always-on
150
+ * host. The supervise tick caps it here with the same tail-keep mechanism as the
151
+ * cmd-logs. logDir (logs/lifecycle) holds ONLY pane-logs, so only top-level *.log is
152
+ * touched — never the rotated logs elsewhere. KEEP_BYTES stays ≥ the reader seed
153
+ * window (see PANELOG_KEEP_BYTES). Best-effort by construction. */
154
+ export function capPaneLogs(
155
+ logDir: string,
156
+ capBytes: number = PANELOG_CAP_BYTES,
157
+ keepBytes: number = PANELOG_KEEP_BYTES,
158
+ ): string[] {
159
+ const capped: string[] = []
160
+ let names: string[]
161
+ try {
162
+ names = readdirSync(logDir)
163
+ } catch {
164
+ return capped
165
+ }
166
+ for (const name of names) {
167
+ if (!name.endsWith('.log')) continue
168
+ const path = join(logDir, name)
169
+ if (tailKeepFile(path, capBytes, keepBytes)) capped.push(path)
170
+ }
171
+ return capped
172
+ }
@@ -63,6 +63,24 @@ export function isFoundationOwnedPlist(path: string): boolean {
63
63
  }
64
64
  }
65
65
 
66
+ /** Read one EnvironmentVariables value from an already-written plist. Used to
67
+ * PRESERVE a host-specific env var across a plist regeneration (install-runtime
68
+ * rewrites the plist from scratch, which would otherwise drop it). Returns
69
+ * undefined when the file / key is absent or unreadable. The key is a literal
70
+ * env-var name (no regex metachars) and the values we preserve are constrained
71
+ * identifiers (no XML entities), so a direct `<key>…</key><string>…</string>`
72
+ * match is sufficient. */
73
+ function readPlistEnvVar(path: string, key: string): string | undefined {
74
+ try {
75
+ const m = readFileSync(path, 'utf8').match(
76
+ new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`),
77
+ )
78
+ return m ? m[1] : undefined
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
66
84
  /**
67
85
  * Resolve an executable to an ABSOLUTE path against `env.PATH` (the RICH
68
86
  * provisioning PATH), so the result can be baked into a launchd plist whose own
@@ -576,6 +594,22 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
576
594
  const resolved = opts.runtimeBin ?? resolveExecutable(INFRA_RUNTIME_DEFAULT_BIN[opts.runtime] ?? opts.runtime, env)
577
595
  if (resolved) environment[binEnvVar] = resolved
578
596
  }
597
+ // Host-specific notifier fallback backstop (NOTIFIER_FALLBACK_TARGET): the final
598
+ // human target the notifier's alarm chain falls back to when every other delivery
599
+ // fails. DELIBERATELY host-resolved at generation time (like NOTIFIER_RUNTIME_BIN
600
+ // above), NEVER a hardcoded literal — a baked peer name would ship in the public
601
+ // package and inject a non-existent target into every foreign install's plist (the
602
+ // exact foreign-install break the notifier package removed from its own default).
603
+ // Source: env when the operator sets it, else PRESERVE the value already baked in
604
+ // THIS host's existing plist so a regeneration (install-runtime rewrites the file)
605
+ // never drops it. Unset on a fresh / foreign host → not baked → the notifier
606
+ // package's own default applies. Notifier-only (the var is notifier-scoped).
607
+ if (opts.runtime === 'notifier') {
608
+ const fallback =
609
+ env.NOTIFIER_FALLBACK_TARGET?.trim() ||
610
+ (existsSync(path) ? readPlistEnvVar(path, 'NOTIFIER_FALLBACK_TARGET') : undefined)
611
+ if (fallback) environment.NOTIFIER_FALLBACK_TARGET = fallback
612
+ }
579
613
  const spec: LaunchdPlistSpec = {
580
614
  label: launchdLabel(opts.personality),
581
615
  programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
@@ -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, cmdLogDirFor } from '../launch/cmdlog.ts'
29
+ import { capCmdLogs, capPaneLogs, cmdLogDirFor } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
31
  import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
32
32
  import {
@@ -1217,6 +1217,14 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1217
1217
  // over the cap. Best-effort by construction; the killer's command is the LAST
1218
1218
  // line of a dying log, so capping a LIVE peer's log never costs the death tail.
1219
1219
  capCmdLogs(cfg.eventLogDir)
1220
+ // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1221
+ // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
1222
+ // append for a session's whole life — it had NO bound and grew to hundreds of MB
1223
+ // per warm peer on an always-on host. Tail-keep it each tick (KEEP ≥ the occupancy
1224
+ // reader's 4 MiB seed window, so capping never starves composer detection).
1225
+ // Not H4-gated: log janitoring is orthogonal to lifecycle ownership, so launchd-
1226
+ // managed peers' pane-logs are capped too. Best-effort by construction.
1227
+ capPaneLogs(cfg.logDir)
1220
1228
  // Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
1221
1229
  // so a postmortem can answer "when & how did peer X's prior session end" even
1222
1230
  // after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd