@agfpd/iapeer 0.2.34 → 0.2.36

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.34",
3
+ "version": "0.2.36",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -145,6 +145,29 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
145
145
  expect(isStopped(cfg, 'claude-parked')).toBe(false)
146
146
  expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
147
147
  })
148
+ test('start --all clears the flag of EVERY registered peer; unregistered garbage untouched (incident 11.06)', async () => {
149
+ await register('p1')
150
+ await register('p2')
151
+ const e = env()
152
+ const cfg = loadLifecycleConfig(e)
153
+ // a stopped fleet …
154
+ setStopped(cfg, 'claude-p1')
155
+ setStopped(cfg, 'claude-p2')
156
+ // … plus a stray stop-flag for an identity NO registry record claims
157
+ setStopped(cfg, 'claude-ghost')
158
+ expect(await runCli(['start', '--all'], e)).toBe(0)
159
+ expect(isStopped(cfg, 'claude-p1')).toBe(false)
160
+ expect(isStopped(cfg, 'claude-p2')).toBe(false)
161
+ expect(isStopped(cfg, 'claude-ghost')).toBe(true) // unregistered → never enumerated, never touched
162
+ })
163
+ test('start without a peer and without --all → usage, nothing executed', async () => {
164
+ await register('p3')
165
+ const e = env()
166
+ const cfg = loadLifecycleConfig(e)
167
+ setStopped(cfg, 'claude-p3')
168
+ expect(await runCli(['start'], e)).toBe(2)
169
+ expect(isStopped(cfg, 'claude-p3')).toBe(true) // flag untouched — usage only
170
+ })
148
171
  })
149
172
 
150
173
  describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
package/src/cli/index.ts CHANGED
@@ -445,7 +445,7 @@ export async function sendMessage(
445
445
  // unawaited in-process wake would die with it; the daemon's supervise-tick
446
446
  // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
447
447
  // kicks, not a new mechanism.
448
- const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
448
+ const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps, makeNoteLiveTopic } = await import('../daemon/main.ts')
449
449
  const cfg = loadLifecycleConfig(env)
450
450
  const t0 = Date.now()
451
451
  const result = await routeSend(
@@ -457,7 +457,10 @@ export async function sendMessage(
457
457
  topic: opts.topic,
458
458
  attachments: opts.attachments,
459
459
  },
460
- { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
460
+ // noteLiveTopic — CLI-path parity (same seam the daemon wires): a live delivery
461
+ // through the CLI fallback must update the target's .topic marker too, or a
462
+ // daemon-restart-window send re-opens the stale-marker false-fresh (defect 11.06).
463
+ { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}), noteLiveTopic: makeNoteLiveTopic(cfg, env) },
461
464
  )
462
465
  // delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
463
466
  // routed through the CLI left to=<peer> at ZERO for the day while real wakes
@@ -552,8 +555,10 @@ const USAGE = `usage: iapeer <verb> [args]
552
555
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
553
556
  list [--json] registered peers + per-runtime liveness
554
557
  verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
555
- stop <peer> [runtime] | --all durable-stop a warm peer / bootout an always-on one
556
- start <peer> [runtime] re-enable a stopped peer / bootstrap an always-on one
558
+ stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
559
+ until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
560
+ start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
561
+ it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
557
562
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
558
563
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
559
564
  <runtime> launch the cwd's peer (ALWAYS fresh)
@@ -948,8 +953,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
948
953
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
949
954
  }
950
955
  case 'start': {
951
- if (!positionals[0]) return usage(errOut)
952
- const outcomes = startPeer(positionals[0], positionals[1], { env })
956
+ // --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
957
+ // incident: bringing a stopped fleet back required a manual loop over ~25
958
+ // peers). Enumeration is the registry, so unregistered garbage (stray flag
959
+ // files for identities no record claims) is never touched. NOTE: for warm
960
+ // peers this clears the stop flag only — no session is launched; each peer
961
+ // becomes wakeable and the daemon brings its session up on the first message.
962
+ const peers = flags.all === true
963
+ ? readPeersIndex({ env }).peers.map(p => p.personality)
964
+ : positionals[0]
965
+ ? [positionals[0]]
966
+ : null
967
+ if (!peers) return usage(errOut)
968
+ const outcomes = peers.flatMap(p => startPeer(p, flags.all === true ? undefined : positionals[1], { env }))
953
969
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
954
970
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
955
971
  }
@@ -1113,7 +1129,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1113
1129
  const { installIapeer } = await import('../install/index.ts')
1114
1130
  ensureGlobalIapScaffold({ env })
1115
1131
  const r = installIapeer(fileURLToPath(import.meta.url), env)
1116
- const plist = installDaemonPlist({ env })
1132
+ const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
1117
1133
  const signingLine =
1118
1134
  r.signing == null
1119
1135
  ? ''
@@ -1126,7 +1142,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1126
1142
  `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
1127
1143
  signingLine +
1128
1144
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
1129
- ` daemon plist written: ${plist}\n` +
1145
+ ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
1130
1146
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
1131
1147
  )
1132
1148
  return 0
@@ -1135,8 +1151,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1135
1151
  // Ф-F: the prod daemon entrypoint. The launchd plist runs `iapeer daemon`
1136
1152
  // (the INSTALLED binary), decoupling prod from the mutable src tree.
1137
1153
  if (flags['install-plist'] === true) {
1138
- const p = installDaemonPlist({ env })
1139
- out(`installed daemon plist: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
1154
+ const { path: p, changed } = installDaemonPlist({ env })
1155
+ out(`daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
1140
1156
  return 0
1141
1157
  }
1142
1158
  const handle = await startConfiguredDaemon({
@@ -188,6 +188,10 @@ export interface CallToolDeps {
188
188
  onDelivered?: (caller: ResolvedCaller) => void
189
189
  /** Ephemeral-target serial-queue delivery (M3) — see transport.EphemeralRouteDeps. */
190
190
  ephemeral?: EphemeralRouteDeps
191
+ /** LIVE-delivered topic → the target's `.topic` marker (fresh-vs-resume seam) —
192
+ * see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
193
+ * lifecycle; the production main wires makeNoteLiveTopic. */
194
+ noteLiveTopic?: (identity: string, topic: string) => void
191
195
  }
192
196
 
193
197
  export async function callTool(
@@ -205,7 +209,7 @@ export async function callTool(
205
209
  attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
206
210
  }
207
211
  const t0 = Date.now()
208
- const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral })
212
+ const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral, noteLiveTopic: deps.noteLiveTopic })
209
213
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
210
214
  // to lifecycle.log) — metadata only, never the body. Both branches of the
211
215
  // routeSend result are recorded, so a suspected loss is reconstructable from
@@ -367,6 +371,12 @@ export interface StartDaemonOptions extends StorageOptions {
367
371
  * lifecycle queue + drain (makeEphemeralRouteDeps).
368
372
  */
369
373
  ephemeral?: EphemeralRouteDeps
374
+ /**
375
+ * LIVE-delivered topic → the target identity's `.topic` marker (fresh-vs-resume
376
+ * seam) — see transport.RouteDeps.noteLiveTopic. OFF by default; the production
377
+ * main wires the lifecycle marker write (makeNoteLiveTopic).
378
+ */
379
+ noteLiveTopic?: (identity: string, topic: string) => void
370
380
  /**
371
381
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
372
382
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -399,6 +409,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
399
409
  deliveryLogDir: opts.deliveryLogDir,
400
410
  onDelivered: opts.onDelivered,
401
411
  ephemeral: opts.ephemeral,
412
+ noteLiveTopic: opts.noteLiveTopic,
402
413
  })
403
414
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
404
415
  res.on('close', () => {
@@ -15,6 +15,7 @@ import {
15
15
  installDaemonPlist,
16
16
  makeArmEphemeralOnDelivered,
17
17
  makeEphemeralRouteDeps,
18
+ makeNoteLiveTopic,
18
19
  startConfiguredDaemon,
19
20
  DEFAULT_DAEMON_PORT,
20
21
  } from './main.ts'
@@ -23,6 +24,10 @@ import {
23
24
  ephemeralQueueDepth,
24
25
  hasEphemeralArmed,
25
26
  peekEphemeralTask,
27
+ readTopic,
28
+ resolveWakeMode,
29
+ setIdleReaped,
30
+ writeTopic,
26
31
  type LifecycleConfig,
27
32
  } from '../lifecycle/index.ts'
28
33
  import type { ResolvedCaller } from '../identity/index.ts'
@@ -70,17 +75,40 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
70
75
  HOME: root,
71
76
  PATH: '/usr/bin:/bin',
72
77
  } as NodeJS.ProcessEnv
73
- const path = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
74
- expect(path).toBe(daemonPlistPath(env))
75
- expect(existsSync(path)).toBe(true)
76
- expect(isFoundationOwnedPlist(path)).toBe(true)
77
- expect(plutilLint(path)).toBe(true) // live plutil: valid plist
78
- const xml = readFileSync(path, 'utf8')
78
+ const r = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
79
+ expect(r.path).toBe(daemonPlistPath(env))
80
+ expect(r.changed).toBe(true) // first install on a clean host writes
81
+ expect(existsSync(r.path)).toBe(true)
82
+ expect(isFoundationOwnedPlist(r.path)).toBe(true)
83
+ expect(plutilLint(r.path)).toBe(true) // live plutil: valid plist
84
+ const xml = readFileSync(r.path, 'utf8')
79
85
  expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
80
86
  expect(xml).toContain('<key>RunAtLoad</key>')
81
87
  expect(xml).toContain('<key>KeepAlive</key>')
82
88
  })
83
89
 
90
+ test('idempotent by content: a re-install of the SAME plist does NOT rewrite the file (changed:false, mtime stable) — no BTM notification spam (owner-caught 11.06)', () => {
91
+ const root = mkTmp()
92
+ const env = {
93
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
94
+ IAPEER_ROOT: join(root, 'iapeer'),
95
+ HOME: root,
96
+ PATH: '/usr/bin:/bin',
97
+ } as NodeJS.ProcessEnv
98
+ const opts = { env, port: 8765, throttleIntervalSecs: 10 }
99
+ const first = installDaemonPlist(opts)
100
+ expect(first.changed).toBe(true)
101
+ const mtime1 = statSync(first.path).mtimeMs
102
+ // second identical install — must be a pure no-op (no write → BTM not tripped)
103
+ const second = installDaemonPlist(opts)
104
+ expect(second.changed).toBe(false)
105
+ expect(second.path).toBe(first.path)
106
+ expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched, not just byte-equal
107
+ // a CHANGED spec (different port → different rendered plist) DOES rewrite
108
+ const third = installDaemonPlist({ env, port: 9999, throttleIntervalSecs: 10 })
109
+ expect(third.changed).toBe(true)
110
+ })
111
+
84
112
  test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
85
113
  const root = mkTmp()
86
114
  const laDir = join(root, 'LaunchAgents')
@@ -91,6 +119,8 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
91
119
  writeFileSync(path, foreign)
92
120
  expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
93
121
  expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
122
+ // (the throw must fire BEFORE the write-if-changed read, so the foreign file
123
+ // is never even compared — the guard is the first gate.)
94
124
  })
95
125
  })
96
126
 
@@ -304,3 +334,61 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
304
334
  expect(r.ok).toBe(false) // 'codex' is not declared for this worker
305
335
  })
306
336
  })
337
+
338
+ // ─────────────────────────────────────────────────────────────────────────────
339
+ // makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
340
+ // the .topic marker must track the topic the session last WORKED ON, not the one
341
+ // it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
342
+ // stop→start resume of a long-lived executor).
343
+ // ─────────────────────────────────────────────────────────────────────────────
344
+
345
+ describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
346
+ function noteCfg(): LifecycleConfig {
347
+ const root = mkTmp()
348
+ return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
349
+ }
350
+
351
+ test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
352
+ const cfg = noteCfg()
353
+ const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
354
+ writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
355
+ note('claude-x', 'topic-b') // a live delivery switches the work to B
356
+ expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
357
+ const logPath = join(cfg.eventLogDir, 'lifecycle.log')
358
+ const log = readFileSync(logPath, 'utf8')
359
+ expect(log).toContain('ev=topic-note')
360
+ expect(log).toContain('identity=claude-x')
361
+ expect(log).toContain('topic=topic-b')
362
+ // the common case — every further message continues topic B → strictly nothing
363
+ note('claude-x', 'topic-b')
364
+ note('claude-x', 'topic-b')
365
+ expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
366
+ })
367
+
368
+ test('acceptance scenario (boris 11.06): woke with topic A → worked live-delivered topic B → clean stop-park → wake with topic B = RESUME, not fresh', () => {
369
+ const cfg = noteCfg()
370
+ const id = 'claude-x'
371
+ // executor peer cwd: no telegram interface (not human-conversational), no wake_policy
372
+ const cwd = mkTmp()
373
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
374
+ writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
375
+ writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
376
+ makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
377
+ setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
378
+ const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
379
+ // pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
380
+ expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
381
+ resume: true,
382
+ resumeRef: 'uuid-1',
383
+ cause: 'idle-reaped-resume',
384
+ })
385
+ })
386
+
387
+ test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
388
+ const root = mkTmp()
389
+ const blocker = join(root, 'state-is-a-file')
390
+ writeFileSync(blocker, 'not a dir')
391
+ const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
392
+ expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
393
+ })
394
+ })
@@ -15,7 +15,7 @@
15
15
  // action (`--install-plist`); it writes the file but does NOT load it — a live
16
16
  // `launchctl bootstrap` stays a deliberate operator step.
17
17
 
18
- import { existsSync, mkdirSync, writeFileSync } from 'fs'
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
19
19
  import { homedir } from 'os'
20
20
  import { join } from 'path'
21
21
  import { fileURLToPath } from 'url'
@@ -35,12 +35,15 @@ import {
35
35
  isEphemeralPeer,
36
36
  loadLifecycleConfig,
37
37
  processEagerRelaunches,
38
+ readTopic,
38
39
  resolveWakeRuntime,
39
40
  setEphemeralArmed,
40
41
  superviseTick,
41
42
  wakeOrSpawn,
43
+ writeTopic,
42
44
  type LifecycleConfig,
43
45
  } from '../lifecycle/index.ts'
46
+ import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
44
47
  import { buildProcessAddress } from '../core/socket.ts'
45
48
  import { err, ok } from '../core/errors.ts'
46
49
  import type { ResolvedCaller } from '../identity/index.ts'
@@ -98,6 +101,35 @@ export function makeArmEphemeralOnDelivered(cfg: LifecycleConfig): (caller: Reso
98
101
  }
99
102
  }
100
103
 
104
+ /**
105
+ * The fourth lifecycle⇆transport seam (after makeWakeFn / makeArmEphemeralOnDelivered /
106
+ * makeEphemeralRouteDeps): record a LIVE-delivered topic as the target identity's
107
+ * `.topic` marker, so the marker tracks the topic the session LAST WORKED ON — the
108
+ * semantics resolveWakeMode's executor discriminator actually wants — not merely the
109
+ * topic it woke with. Live defect 11.06 (boris): an executor woke topicless at 13:13,
110
+ * worked a delivered topic all day, was stop-parked clean, and the topic-matching
111
+ * resume-wake at 17:06 compared against the STALE wake-time marker → cause=
112
+ * idle-reaped-new-topic → FRESH; the day's context was lost despite the clean park.
113
+ * Same-topic deliveries are a no-op (no marker churn, no log spam) — the lifecycle.log
114
+ * line `ev=topic-note` therefore marks exactly the moments the session's working
115
+ * topic SHIFTED, which is what a fresh-vs-resume postmortem needs. Best-effort: a
116
+ * marker/log failure never fails the already-delivered message.
117
+ */
118
+ export function makeNoteLiveTopic(
119
+ cfg: LifecycleConfig,
120
+ env: NodeJS.ProcessEnv = process.env,
121
+ ): (identity: string, topic: string) => void {
122
+ return (identity, topic) => {
123
+ try {
124
+ if (readTopic(cfg, identity) === topic) return
125
+ writeTopic(cfg, identity, topic)
126
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
127
+ } catch {
128
+ /* the marker is a discriminator hint — never fail a delivered message */
129
+ }
130
+ }
131
+ }
132
+
101
133
  /**
102
134
  * wake_policy:"ephemeral" M3 — the serial-queue delivery composition (the third
103
135
  * lifecycle⇆transport seam after makeWakeFn / makeArmEphemeralOnDelivered).
@@ -178,6 +210,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
178
210
  // M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
179
211
  // async drain. No-op for every other target.
180
212
  ephemeral: makeEphemeralRouteDeps(cfg, env),
213
+ // Live-delivered topic → the target's .topic marker (see makeNoteLiveTopic):
214
+ // the wake resolver's executor discriminator then compares against the topic
215
+ // the session last WORKED ON, not the one it woke with (defect 11.06).
216
+ noteLiveTopic: makeNoteLiveTopic(cfg, env),
181
217
  supervise: {
182
218
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
183
219
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -265,17 +301,33 @@ export function buildDaemonPlistSpec(opts: InstallDaemonPlistOptions = {}): Laun
265
301
  }
266
302
  }
267
303
 
304
+ export interface InstallDaemonPlistResult {
305
+ /** The plist path (always returned, written or not). */
306
+ path: string
307
+ /** false when the on-disk plist already matched byte-for-byte → NO write was
308
+ * done. The deploy path calls this on EVERY `iapeer update`; a byte-identical
309
+ * rewrite serves nothing (the daemon never re-reads the file — launchd holds
310
+ * its loaded definition; a changed file takes effect only on an explicit
311
+ * bootout+bootstrap) yet each write into ~/Library/LaunchAgents trips macOS
312
+ * Background Task Management → a "background activity" notification to the
313
+ * owner on every deploy. Skipping the no-op write suppresses that noise. */
314
+ changed: boolean
315
+ }
316
+
268
317
  /**
269
318
  * Generate + install the daemon's always-on launchd plist at
270
319
  * ~/Library/LaunchAgents/com.agfpd.iapeer.plist (or IAPEER_LAUNCHAGENTS_DIR),
271
- * returning the path. Writes the FILE only — does NOT bootstrap it (load is a
272
- * deliberate operator step). Collision guard: refuses to overwrite an existing
273
- * com.agfpd.iapeer.plist that is not foundation-owned (lacks the sentinel) — the
274
- * same ownership proof the peer-plist installer uses. com.agfpd.* is the
275
- * foundation-exclusive daemon namespace, so this never touches the com.iapeer.*
276
- * persistent-peer fleet.
320
+ * returning {path, changed}. Writes the FILE only — does NOT bootstrap it (load
321
+ * is a deliberate operator step). IDEMPOTENT BY CONTENT: writes only when the
322
+ * rendered plist differs from what is already on disk (owner-caught 11.06: an
323
+ * unconditional rewrite on every deploy spams the owner's Background Task
324
+ * Management notification for zero benefit). Collision guard: refuses to
325
+ * overwrite an existing com.agfpd.iapeer.plist that is not foundation-owned
326
+ * (lacks the sentinel) — the same ownership proof the peer-plist installer uses.
327
+ * com.agfpd.* is the foundation-exclusive daemon namespace, so this never
328
+ * touches the com.iapeer.* persistent-peer fleet.
277
329
  */
278
- export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string {
330
+ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): InstallDaemonPlistResult {
279
331
  const env = opts.env ?? process.env
280
332
  const path = daemonPlistPath(env)
281
333
  if (existsSync(path) && !isFoundationOwnedPlist(path)) {
@@ -284,11 +336,20 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string
284
336
  `(no ownership sentinel) — another manager owns it`,
285
337
  )
286
338
  }
287
- const spec = buildDaemonPlistSpec(opts)
339
+ const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
288
340
  mkdirSync(launchAgentsDir(env), { recursive: true })
289
341
  mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
290
- writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
291
- return path
342
+ // Write-if-changed: a byte-identical rewrite is a pure no-op for the daemon but
343
+ // trips the owner's BTM notification — skip it (see InstallDaemonPlistResult).
344
+ let existing: string | null = null
345
+ try {
346
+ existing = readFileSync(path, 'utf8')
347
+ } catch {
348
+ existing = null
349
+ }
350
+ if (existing === rendered) return { path, changed: false }
351
+ writeFileSync(path, rendered, { mode: 0o644 })
352
+ return { path, changed: true }
292
353
  }
293
354
 
294
355
  // ─────────────────────────────────────────────────────────────────────────────
@@ -301,9 +362,9 @@ if (import.meta.main) {
301
362
  process.stdout.write(renderLaunchdPlist(buildDaemonPlistSpec()))
302
363
  process.exit(0)
303
364
  } else if (arg === '--install-plist') {
304
- const p = installDaemonPlist()
365
+ const { path: p, changed } = installDaemonPlist()
305
366
  process.stdout.write(
306
- `installed daemon plist: ${p}\n` +
367
+ `daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\n` +
307
368
  `NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
308
369
  )
309
370
  process.exit(0)
@@ -223,8 +223,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
223
223
  // Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
224
224
  // wait for a message) and injects initial_prompt. Consumed on the relaunch.
225
225
  // .deaths : crash-loop guard — a small JSON ring of recent death epoch-ms.
226
- // .topic : the topic tag of the current/last session (executor fresh-vs-
227
- // resume discriminator).
226
+ // .topic : the topic the current/last session last WORKED ON (executor
227
+ // fresh-vs-resume discriminator). Written at wake (the boot topic) AND on
228
+ // every live delivery carrying a different non-empty topic (the daemon/CLI
229
+ // compositions wire transport's noteLiveTopic seam to writeTopic — defect
230
+ // 11.06: a wake-only marker goes stale over a long session and false-freshes
231
+ // the stop→start resume the clean park promised).
228
232
  // .ephemeral-armed : wake_policy:ephemeral M2 — set by the DAEMON when it routes
229
233
  // an OUTBOUND send from an ephemeral worker (its single final reply, ADR-006:
230
234
  // workers send no intermediate messages, so outbound ⇒ the task is answered).
@@ -381,7 +385,9 @@ export function readTopic(cfg: LifecycleConfig, identity: string): string {
381
385
  }
382
386
  }
383
387
 
384
- /** Store the incoming topic for the established session (raw string, best-effort). */
388
+ /** Store the session's working topic (raw string, best-effort). Two writers: the
389
+ * wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic seam
390
+ * (makeNoteLiveTopic) — so the marker follows the work, not just the boot. */
385
391
  export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
386
392
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
387
393
  try {
@@ -440,6 +440,29 @@ export interface RouteDeps {
440
440
  wake?: WakeFn
441
441
  /** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
442
442
  ephemeral?: EphemeralRouteDeps
443
+ /** Fired after a successful LIVE delivery whose envelope carried a non-empty
444
+ * topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
445
+ * first-message (the wake itself records its topic). The fresh-vs-resume seam:
446
+ * lifecycle keeps a per-identity `.topic` marker the wake resolver compares
447
+ * the incoming topic against; before this hook the marker was written ONLY at
448
+ * wake, so it held «the topic the session woke with», while the resolver's
449
+ * semantics want «the topic the session last worked on». A long-lived executor
450
+ * that took live-delivered messages of topic B all day then false-FRESHED on
451
+ * stop→start→wake with topic B (live incident 11.06, claude-iapeer: a day of
452
+ * context lost to cause=idle-reaped-new-topic). Injected so transport never
453
+ * imports lifecycle (layering — main.ts is where the two meet); call sites
454
+ * swallow hook errors (a hook must never fail a delivered message). */
455
+ noteLiveTopic?: (identity: string, topic: string) => void
456
+ }
457
+
458
+ /** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
459
+ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined): void {
460
+ if (!topic) return
461
+ try {
462
+ deps.noteLiveTopic?.(identity, topic)
463
+ } catch {
464
+ /* a post-delivery hook must never fail the delivery */
465
+ }
443
466
  }
444
467
 
445
468
  function truncateTopic(raw: string | undefined): string | undefined {
@@ -534,6 +557,7 @@ export async function routeSend(
534
557
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
535
558
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
536
559
  if (!delivered.ok) return delivered
560
+ noteTopic(deps, target.value.address, topic)
537
561
  return ok({
538
562
  ok: true,
539
563
  delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
@@ -567,6 +591,7 @@ export async function routeSend(
567
591
  if (woke.taskDelivered === false) {
568
592
  const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
569
593
  if (!delivered.ok) return delivered
594
+ noteTopic(deps, live.value.address, topic)
570
595
  return ok({
571
596
  ok: true,
572
597
  delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
@@ -137,3 +137,72 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
137
137
  expect(r.error.message).toMatch(/verify-before-act|not live/)
138
138
  })
139
139
  })
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // noteLiveTopic seam — a LIVE delivery carrying a topic must surface it to the
143
+ // injected hook (lifecycle's .topic marker write), so the fresh-vs-resume
144
+ // discriminator compares against the topic the session last WORKED ON, not the
145
+ // one it woke with (defect 11.06: stale wake-time marker → false fresh).
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+
148
+ describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
149
+ test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
150
+ startTargetSession()
151
+ const notes: Array<[string, string]> = []
152
+ const r = await routeSend(
153
+ caller,
154
+ { personality: TARGET, message: 'm', topic: 'fix-resume' },
155
+ { noteLiveTopic: (id, t) => notes.push([id, t]) },
156
+ )
157
+ expect(r.ok).toBe(true)
158
+ expect(notes).toEqual([[`telegram-${TARGET}`, 'fix-resume']])
159
+ killTargetSession()
160
+ })
161
+
162
+ test('no topic → hook NOT fired; a throwing hook never fails the delivered message', async () => {
163
+ startTargetSession()
164
+ const notes: string[] = []
165
+ const r1 = await routeSend(caller, { personality: TARGET, message: 'm1' }, { noteLiveTopic: () => { notes.push('x') } })
166
+ expect(r1.ok).toBe(true)
167
+ expect(notes).toEqual([])
168
+ const r2 = await routeSend(
169
+ caller,
170
+ { personality: TARGET, message: 'm2', topic: 't' },
171
+ { noteLiveTopic: () => { throw new Error('boom') } },
172
+ )
173
+ expect(r2.ok).toBe(true)
174
+ killTargetSession()
175
+ })
176
+
177
+ test('post-wake fast-path redelivery (taskDelivered:false) also notes the topic — it is a LIVE delivery', async () => {
178
+ const notes: Array<[string, string]> = []
179
+ const wake: WakeFn = async () => {
180
+ startTargetSession()
181
+ return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
182
+ }
183
+ const r = await routeSend(
184
+ caller,
185
+ { personality: TARGET, message: 'm', topic: 'tt' },
186
+ { wake, noteLiveTopic: (id, t) => notes.push([id, t]) },
187
+ )
188
+ expect(r.ok).toBe(true)
189
+ expect(notes).toEqual([[`telegram-${TARGET}`, 'tt']])
190
+ killTargetSession()
191
+ })
192
+
193
+ test('boot first-message delivery (woke:true) does NOT fire the hook — the wake records its own topic', async () => {
194
+ const notes: string[] = []
195
+ const wake: WakeFn = async () => {
196
+ startTargetSession()
197
+ return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
198
+ }
199
+ const r = await routeSend(
200
+ caller,
201
+ { personality: TARGET, message: 'm', topic: 'tb' },
202
+ { wake, noteLiveTopic: () => { notes.push('x') } },
203
+ )
204
+ expect(r.ok).toBe(true)
205
+ expect(notes).toEqual([])
206
+ killTargetSession()
207
+ })
208
+ })