@agfpd/iapeer 0.2.80 → 0.2.82

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/README.md CHANGED
@@ -1,29 +1,77 @@
1
1
  # iapeer
2
2
 
3
- Ядро мультиагентной системы на одном хосте: реестр пиров, единый демон-роутер для обмена сообщениями между ними и команды управления — создание, запуск, усыпление, перезапуск, подключение к внешним каналам. Пиром может быть AI-агент (Claude, Codex), человек (через Telegram) или служебный процесс; все адресуются одинаково через один протокол.
3
+ **Host-level core for a team of AI agents on one machine.**
4
4
 
5
- ## Ключевые возможности
5
+ [![npm](https://img.shields.io/npm/v/@agfpd/iapeer)](https://www.npmjs.com/package/@agfpd/iapeer)
6
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
7
+ [![platform](https://img.shields.io/badge/platform-macOS-lightgrey)](#quick-start)
6
8
 
7
- iapeer держит AI-агентов как **живые интерактивные сессии настоящего CLI** (Claude Code, Codex CLI) — со всем поведением реального CLI, а не headless-режим одноразовых вызовов (`claude -p`, `codex exec`). Поскольку это реальный интерактивный CLI, пиры работают в рамках обычной подписки на Claude Code (изменение тарификации headless-режима от 15 июня 2026 интерактивный CLI не затрагивает).
9
+ iapeer keeps a team of AI agents, humans, and services on one host **always reachable — every one of them a single message away.** Each participant is a **peer**: a Claude Code or Codex CLI agent, a person on Telegram, or a scheduler/watcher service. Any agent reaches any other with one call — `send_to_peer(<name>, <text>)` — and a peer that is asleep is still reachable: the daemon wakes it and delivers. Agents on Claude and on Codex — different AI runtimes from different vendors — talk as equal peers; humans take part over Telegram.
8
10
 
9
- Отдельные свойства ниже встречаются и в других инструментах; сильная сторона iapeer — их **комбинация целиком**:
11
+ ## How it works
10
12
 
11
- 1. **Реальный интерактивный CLI в фоне.** Каждый AI-пир — полноценная сессия Claude Code или Codex CLI, поднятая в фоне и доступная по сообщению.
13
+ ```text
14
+ send_to_peer(name, text) ← from any peer
15
+ │
16
+ ▼
17
+ router daemon — always on; finds the recipient, checks liveness, delivers
18
+ │
19
+ ▼
20
+ recipient peer — asleep? the daemon wakes it and delivers on wake
21
+ ```
12
22
 
13
- 2. **Постоянная сессия (persistent).** На входящее демон поднимает пира через возобновление (`--continue` / `--resume`) — диалог **продолжается**, а не начинается заново; контекст и история сохраняются между обращениями.
23
+ ## Quick start
14
24
 
15
- 3. **Событийный warm-on-demand жизненный цикл.** Сессия «тёплая» → быстро обрабатывает входящие; после ~часа простоя демон закрывает её (idle-reap); новое входящее → демон поднимает заново **автоматически**, с возобновлением контекста. Жизненным циклом правит поток сообщений, а не присутствие человека: это не привязанный к разработчику TUI и не одноразовый воркер на задачу. Ресурсы заняты, только пока пир работает.
25
+ Requirements: **macOS**, [bun](https://bun.sh) on `PATH`, `~/.local/bin` on `PATH`, and at least one agent runtime installed — Claude Code or Codex CLI (each AI peer is a live session of one).
16
26
 
17
- 4. **Переносимость пиров между рантаймами.** Личность пира (`personality`) отделена от рантайма: один пир присутствует и в Claude, и в Codex, а основной рантайм переключается командой (`claude ↔ codex`) без потери идентичности и адресации.
27
+ ```sh
28
+ # 1. install — the iapeer binary and the ~/.iapeer layout
29
+ npx @agfpd/iapeer
18
30
 
19
- 5. **Подключение человека к живой сессии.** `iapeer attach` открывает **живую** сессию пира прямо в вашем терминале через pty-супервизор — мышь, прокрутка и цвета работают нативно; отсоединение (`Ctrl-]`) сессию не закрывает.
31
+ # 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
32
+ iapeer onboard
20
33
 
21
- Целиком эту связку — событийный warm-on-demand жизненный цикл (не attached-разработчиком TUI и не воркер-на-задачу), переносимость пиров между рантаймами и единую ткань обмена (протокол IAP, память-vault с поиском, Telegram-мост) — даёт iapeer.
34
+ # 3. create your first agent
35
+ iapeer create assistant --runtime claude
22
36
 
23
- ## Документация
37
+ # 4. talk to it live in your terminal — detach with Ctrl-]
38
+ iapeer attach assistant
39
+ ```
24
40
 
25
- Документация проекта — в [`docs/`](https://github.com/agfpd/iapeer/tree/main/docs): продуктовая часть (что это и как пользоваться) и механика изнутри ([`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals)). Этот репозиторий — кодовая реализация.
41
+ **Already have a project folder?** In step 3, use `init` instead of `create`: `cd` into the project and register that folder itself as a peer.
26
42
 
27
- ## Статус
43
+ ```sh
44
+ cd ~/projects/myagent
45
+ iapeer init
46
+ ```
28
47
 
29
- Платформа — macOS/launchd. Актуальная версия — в `package.json` / `iapeer version`.
48
+ The peer's name is the folder name, normalized (`myagent`). `create` sets up a fresh folder under `~/.iapeer/peers/<name>`; `init` turns the folder you are in into a peer. The setup is identical either way.
49
+
50
+ Two ways to work with an agent locally in your terminal:
51
+
52
+ - **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep; detach with `Ctrl-]` and the session keeps running.
53
+ - **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
54
+
55
+ ```sh
56
+ cd ~/.iapeer/peers/assistant
57
+ iapeer claude
58
+ ```
59
+
60
+ This starts a full session with the peer's identity and doctrine and drops you straight into it. In effect, `iapeer claude` is `claude` for that peer — the same interactive CLI, but the session has a stable identity, its own memory, and an address other peers can reach.
61
+
62
+ Agents reach each other with `send_to_peer`; you can also message any peer over Telegram.
63
+
64
+ ## What makes it different
65
+
66
+ - **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
67
+ - **Warm-on-demand lifecycle.** A live session answers fast; after about an hour idle the daemon closes it; the next message brings it back with its context resumed. The message flow drives the lifecycle — not a human's presence.
68
+ - **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime — the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime: one personality, one memory, whatever runtime it runs on.
69
+ - **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
70
+
71
+ ## Documentation
72
+
73
+ [`docs/`](https://github.com/agfpd/iapeer/tree/main/docs) — the product side (what it is and how to use it); [`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals) — the mechanics from the inside. This repository is the implementation.
74
+
75
+ ## License
76
+
77
+ Apache-2.0. Platform: macOS / launchd. iapeer is the foundation core of a larger ecosystem; runtime and capability plugins build on top of it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.80",
3
+ "version": "0.2.82",
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/cli/index.ts CHANGED
@@ -915,7 +915,7 @@ const USAGE = `usage: iapeer <verb> [args]
915
915
  status host snapshot: version, daemon health, memory slot (<provider> | none)
916
916
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
917
917
  update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
918
- init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
918
+ init [cwd] [--runtime r] [--description d] onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)
919
919
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
920
920
  list [--json] registered peers + per-runtime liveness
921
921
  verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
@@ -1182,11 +1182,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1182
1182
  // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
1183
1183
  // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
1184
1184
  // when not explicit. Auto-bootstraps an infra plist unless --no-bootstrap.
1185
+ // A peer's name IS its folder name (normalize(basename(cwd))) — personality ↔ folder
1186
+ // is 1:1, so `--personality` is gone: name the folder, don't pass a separate name.
1187
+ if (flags.personality !== undefined) {
1188
+ errOut("init: --personality was removed — a peer's name is its folder name; rename the folder to set the name\n")
1189
+ return 2
1190
+ }
1185
1191
  const { initPeer } = await import('../init/index.ts')
1186
1192
  const r = await initPeer({
1187
1193
  cwd: positionals[0] ?? process.cwd(),
1188
1194
  runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
1189
- personality: typeof flags.personality === 'string' ? flags.personality : undefined,
1190
1195
  description: typeof flags.description === 'string' ? flags.description : undefined,
1191
1196
  runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
1192
1197
  bootstrap: flags['no-bootstrap'] === true ? false : undefined,
@@ -35,6 +35,7 @@ import {
35
35
  drainEphemeralQueue,
36
36
  enqueueEphemeralTask,
37
37
  isEphemeralPeer,
38
+ addTopic,
38
39
  loadLifecycleConfig,
39
40
  processEagerRelaunches,
40
41
  readTopic,
@@ -42,7 +43,6 @@ import {
42
43
  setEphemeralArmed,
43
44
  superviseTick,
44
45
  wakeOrSpawn,
45
- writeTopic,
46
46
  type LifecycleConfig,
47
47
  } from '../lifecycle/index.ts'
48
48
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
@@ -135,8 +135,8 @@ export function makeNoteLiveTopic(
135
135
  ): (identity: string, topic: string) => void {
136
136
  return (identity, topic) => {
137
137
  try {
138
- if (readTopic(cfg, identity) === topic) return
139
- writeTopic(cfg, identity, topic)
138
+ if (readTopic(cfg, identity) === topic) return // unchanged most-recent → no shift, skip the note
139
+ addTopic(cfg, identity, topic) // accumulate into the session's topic set (a new live thread)
140
140
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
141
141
  } catch {
142
142
  /* the marker is a discriminator hint — never fail a delivered message */
@@ -415,6 +415,19 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
415
415
  let personality: string
416
416
  if (options.personality !== undefined) {
417
417
  personality = validatePersonality(options.personality, 'personality')
418
+ // INVARIANT (personality ↔ folder is 1:1): an explicitly-supplied personality MUST
419
+ // equal the normalized cwd basename. Otherwise the registry record (personality) and
420
+ // a manual `cd <cwd>; <runtime>` session (which self-derives identity from the cwd
421
+ // basename) would disagree. The only ways to reach a mismatch are a runtime manifest
422
+ // `peers[].path` or `create --path <dir>` whose basename ≠ personality — both are
423
+ // misconfigurations, surfaced loudly here rather than written as a latent drift.
424
+ const expected = normalizeNameCandidate(basename(cwd))
425
+ if (personality !== expected) {
426
+ throw new IapError(
427
+ `personality "${personality}" must equal the normalized cwd basename "${expected}" ` +
428
+ `(personality ↔ folder is 1:1) — name the folder to match, or drop the explicit name. cwd: ${cwd}`,
429
+ )
430
+ }
418
431
  const collision = peerCollision(personality, cwd, peers)
419
432
  if (collision) {
420
433
  throw new IapError(
package/src/init/index.ts CHANGED
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
17
17
  import { homedir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, IAPEER_DIR, isInfraRuntime, type Runtime } from '../core/constants.ts'
20
+ import { IapError } from '../core/errors.ts'
20
21
  import {
21
22
  ensureLocalIapScaffold,
22
23
  pluginStateDir,
@@ -257,11 +258,63 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
257
258
  * the old inconsistency where a `.codex`-only folder defaulted to claude as primary
258
259
  * yet got a codex config — now the primary matches the markers, deterministically.
259
260
  */
260
- export function resolvePrimaryRuntime(cwd: string, explicit?: Runtime): Runtime {
261
- if (explicit) return explicit
261
+ /**
262
+ * Is an AGENTIC runtime installed on this host? Checks (any hit ⇒ installed): env
263
+ * override (IAPEER_<RT>_BIN), the native installer location (~/.local/bin/claude), the
264
+ * bare name on PATH, and the runtime's global config dir (~/.claude / ~/.codex — a
265
+ * runtime that has ever run leaves one). SANDBOX: under IAPEER_TEST_SANDBOX it reports
266
+ * `true` (never probes the real host) — mirror of the install-side guards; a hermetic
267
+ * test that wants the real branches injects its own predicate into resolvePrimaryRuntime.
268
+ */
269
+ export function isRuntimeInstalled(runtime: Runtime, env: NodeJS.ProcessEnv = process.env): boolean {
270
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return true
271
+ const override = runtime === 'claude' ? env.IAPEER_CLAUDE_BIN : runtime === 'codex' ? env.IAPEER_CODEX_BIN : undefined
272
+ if (override?.trim()) return true
273
+ const home = env.HOME?.trim() || homedir()
274
+ if (runtime === 'claude' && existsSync(join(home, '.local', 'bin', 'claude'))) return true
275
+ if (Bun.which(runtime)) return true
276
+ if (runtime === 'claude' && existsSync(join(home, '.claude'))) return true
277
+ if (runtime === 'codex' && existsSync(join(home, '.codex'))) return true
278
+ return false
279
+ }
280
+
281
+ const AGENTIC_RUNTIMES: Runtime[] = ['claude', 'codex']
282
+
283
+ /**
284
+ * Resolve a peer's primary AGENTIC runtime — install-aware:
285
+ * 1. explicit (`--runtime`) → use it, but VALIDATE it is installed (clear error instead
286
+ * of a silent launch failure later on an uninstalled runtime).
287
+ * 2. else a cwd runtime MARKER (.claude / .codex) → that runtime. The folder's already-
288
+ * established config takes PRECEDENCE over install-presence (a folder configured for
289
+ * X stays X even if Y is also installed; markers are not re-validated).
290
+ * 3. else from what is INSTALLED: exactly one → it; none → clear error (don't silently
291
+ * default to claude on a host without it); both → claude (deterministic default —
292
+ * resolution runs in non-interactive paths, so no prompt; `--runtime` picks codex).
293
+ * `isInstalled` is injectable for hermetic tests.
294
+ */
295
+ export function resolvePrimaryRuntime(
296
+ cwd: string,
297
+ explicit?: Runtime,
298
+ isInstalled: (rt: Runtime) => boolean = rt => isRuntimeInstalled(rt),
299
+ ): Runtime {
300
+ if (explicit) {
301
+ if (!isInstalled(explicit)) {
302
+ throw new IapError(
303
+ `runtime "${explicit}" is not installed on this host — install it (Claude Code / Codex CLI) or choose an installed runtime`,
304
+ )
305
+ }
306
+ return explicit
307
+ }
262
308
  if (existsSync(join(cwd, '.claude'))) return 'claude'
263
309
  if (existsSync(join(cwd, '.codex'))) return 'codex'
264
- return 'claude'
310
+ const installed = AGENTIC_RUNTIMES.filter(isInstalled)
311
+ if (installed.length === 1) return installed[0]!
312
+ if (installed.length === 0) {
313
+ throw new IapError(
314
+ `no agentic runtime installed (Claude Code or Codex CLI) — install one, then create/init the peer`,
315
+ )
316
+ }
317
+ return 'claude' // both installed → deterministic default; pass --runtime codex to pick codex
265
318
  }
266
319
 
267
320
  export interface InitPeerOptions {
@@ -319,7 +372,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
319
372
  // Runtime-aware + CONSISTENT: an explicit runtime wins; else the cwd's markers
320
373
  // decide the primary (resolvePrimaryRuntime), so the primary always matches the
321
374
  // config that gets written (no `.codex`-only-but-primary-claude mismatch).
322
- const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime)
375
+ const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime, rt => isRuntimeInstalled(rt, env))
323
376
  const provisioned = await provisionPeer({
324
377
  cwd: opts.cwd,
325
378
  runtime,
@@ -376,27 +376,63 @@ function topicPath(cfg: LifecycleConfig, identity: string): string {
376
376
  return join(cfg.stateDir, `${identity}.topic`)
377
377
  }
378
378
 
379
- /** Read the stored topic of the current/last session (executor discriminator). '' if none. */
380
- export function readTopic(cfg: LifecycleConfig, identity: string): string {
379
+ // Per-session TOPIC SET (executor resume discriminator). The `.topic` file holds the
380
+ // topics this session lineage has touched — newline-separated, most-recent LAST, deduped
381
+ // and capped. A peer can carry several threads in ONE session, so resume matches the
382
+ // incoming topic against ANY topic in the set: a later ping on an earlier thread continues
383
+ // that session's context instead of starting fresh and redoing work. The set is RESET on a
384
+ // FRESH wake (new lineage) and ACCUMULATES on the wake topic + every live delivery topic.
385
+ const MAX_SESSION_TOPICS = 16
386
+
387
+ /** All topics the current session lineage has touched (most-recent last). [] if none. */
388
+ export function readTopics(cfg: LifecycleConfig, identity: string): string[] {
381
389
  try {
382
- return readFileSync(topicPath(cfg, identity), 'utf8').trim()
390
+ return readFileSync(topicPath(cfg, identity), 'utf8')
391
+ .split('\n')
392
+ .map(t => t.trim())
393
+ .filter(Boolean)
383
394
  } catch {
384
- return ''
395
+ return []
385
396
  }
386
397
  }
387
398
 
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. */
391
- export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
399
+ /** The session's MOST-RECENT topic (last in the set), or '' — single-value back-compat read. */
400
+ export function readTopic(cfg: LifecycleConfig, identity: string): string {
401
+ const t = readTopics(cfg, identity)
402
+ return t.length ? t[t.length - 1]! : ''
403
+ }
404
+
405
+ /** Is `topic` (non-empty) one of the session lineage's topics? Drives resume-vs-fresh. */
406
+ export function hasTopic(cfg: LifecycleConfig, identity: string, topic: string): boolean {
407
+ const t = topic.trim()
408
+ return !!t && readTopics(cfg, identity).includes(t)
409
+ }
410
+
411
+ /** Add a topic to the session's set (most-recent-last, deduped, capped). Empty → no-op.
412
+ * Two callers: the wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic
413
+ * seam (makeNoteLiveTopic) — so the set follows the work, not just the boot. Best-effort. */
414
+ export function addTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
415
+ const t = topic.trim()
416
+ if (!t) return
392
417
  mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
393
418
  try {
394
- writeFileSync(topicPath(cfg, identity), topic, { mode: 0o600 })
419
+ const set = readTopics(cfg, identity).filter(x => x !== t)
420
+ set.push(t)
421
+ writeFileSync(topicPath(cfg, identity), set.slice(-MAX_SESSION_TOPICS).join('\n') + '\n', { mode: 0o600 })
395
422
  } catch {
396
423
  /* best-effort — topic is a discriminator hint, never blocks a wake */
397
424
  }
398
425
  }
399
426
 
427
+ /** Drop the session's topic set — a FRESH wake starts a new lineage with no carried topics. */
428
+ export function resetTopics(cfg: LifecycleConfig, identity: string): void {
429
+ try {
430
+ rmSync(topicPath(cfg, identity), { force: true })
431
+ } catch {
432
+ /* best-effort */
433
+ }
434
+ }
435
+
400
436
  /**
401
437
  * Purge EVERY identity-keyed lifecycle artifact of `<identity>` from stateDir:
402
438
  * the marker files (`.stopped` / `.idle-reaped` / `.deaths` / `.topic` /
@@ -555,10 +591,17 @@ export function resolveWakeMode(
555
591
  ? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-human' }
556
592
  : { resume: false, cause: 'idle-reaped-human-no-resume' }
557
593
  }
558
- // executor: a NEW topic (non-empty and differing from the stored one) means new
559
- // work → FRESH; same topic, or no incoming topic → continue the work → RESUME.
594
+ // executor: RESUME is an INTENTIONAL "continue THIS work" signal — it happens ONLY when
595
+ // the incoming message carries a topic that matches the session's last topic. A DIFFERENT
596
+ // topic (new work) OR NO topic at all → FRESH. Rationale: a topicless ping after an idle
597
+ // gap is not a request to resume specific work; defaulting it to resume revived stale
598
+ // context (e.g. a 3-day-old session). The sender opts INTO continuity by passing the topic.
560
599
  const topic = incomingTopic?.trim() ?? ''
561
- if (topic && topic !== readTopic(cfg, identity)) return { resume: false, cause: 'idle-reaped-new-topic' }
600
+ if (!topic) return { resume: false, cause: 'idle-reaped-no-topic' }
601
+ // RESUME if the incoming topic matches ANY thread this session lineage worked on — not
602
+ // just the last. Different threads accumulate in one session; a later ping on an earlier
603
+ // thread continues that context. A topic the session never touched → new work → FRESH.
604
+ if (!hasTopic(cfg, identity, topic)) return { resume: false, cause: 'idle-reaped-new-topic' }
562
605
  const r = resolveResume(cwd)
563
606
  return r.ok
564
607
  ? { resume: true, resumeRef: r.ref, cause: 'idle-reaped-resume' }
@@ -1053,9 +1096,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1053
1096
  // reply. ONLY here on the actual launch path, NEVER on the live-session fast path
1054
1097
  // above (a live session may be legitimately armed mid-quiet-window).
1055
1098
  clearEphemeralArmed(cfg, identity)
1056
- // Establish the session's topic (executor discriminator) and reset the crash-loop
1057
- // ring — a successful wake means this is NOT a tight crash loop. Best-effort.
1058
- writeTopic(cfg, identity, args.topic?.trim() ?? '')
1099
+ // Establish/extend the session's topic SET (executor resume discriminator) and reset the
1100
+ // crash-loop ring. A FRESH wake starts a new lineage → drop the old topic set; a RESUME
1101
+ // keeps accumulating. The wake's own topic joins the set (no-op if empty). resolveWakeMode
1102
+ // already read the OLD set above, so mutating here is safe. Best-effort.
1103
+ if (!mode.resume) resetTopics(cfg, identity)
1104
+ addTopic(cfg, identity, args.topic?.trim() ?? '')
1059
1105
  trimDeaths(cfg, identity, cfg.crashLoopWindowSecs, Date.now())
1060
1106
  return { status: 'READY', woke: true, runtime, process_address: identity, taskDelivered: true }
1061
1107
  })