@agfpd/iapeer 0.2.34 → 0.2.35

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.35",
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
  }
@@ -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'
@@ -304,3 +309,61 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
304
309
  expect(r.ok).toBe(false) // 'codex' is not declared for this worker
305
310
  })
306
311
  })
312
+
313
+ // ─────────────────────────────────────────────────────────────────────────────
314
+ // makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
315
+ // the .topic marker must track the topic the session last WORKED ON, not the one
316
+ // it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
317
+ // stop→start resume of a long-lived executor).
318
+ // ─────────────────────────────────────────────────────────────────────────────
319
+
320
+ describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
321
+ function noteCfg(): LifecycleConfig {
322
+ const root = mkTmp()
323
+ return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
324
+ }
325
+
326
+ test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
327
+ const cfg = noteCfg()
328
+ const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
329
+ writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
330
+ note('claude-x', 'topic-b') // a live delivery switches the work to B
331
+ expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
332
+ const logPath = join(cfg.eventLogDir, 'lifecycle.log')
333
+ const log = readFileSync(logPath, 'utf8')
334
+ expect(log).toContain('ev=topic-note')
335
+ expect(log).toContain('identity=claude-x')
336
+ expect(log).toContain('topic=topic-b')
337
+ // the common case — every further message continues topic B → strictly nothing
338
+ note('claude-x', 'topic-b')
339
+ note('claude-x', 'topic-b')
340
+ expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
341
+ })
342
+
343
+ 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', () => {
344
+ const cfg = noteCfg()
345
+ const id = 'claude-x'
346
+ // executor peer cwd: no telegram interface (not human-conversational), no wake_policy
347
+ const cwd = mkTmp()
348
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
349
+ writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
350
+ writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
351
+ makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
352
+ setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
353
+ const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
354
+ // pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
355
+ expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
356
+ resume: true,
357
+ resumeRef: 'uuid-1',
358
+ cause: 'idle-reaped-resume',
359
+ })
360
+ })
361
+
362
+ test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
363
+ const root = mkTmp()
364
+ const blocker = join(root, 'state-is-a-file')
365
+ writeFileSync(blocker, 'not a dir')
366
+ const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
367
+ expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
368
+ })
369
+ })
@@ -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
@@ -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
+ })