@agfpd/iapeer 0.4.14 → 0.4.16

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/CHANGELOG.md CHANGED
@@ -10,14 +10,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.16] - 2026-06-21
14
+
15
+ ### Fixed
16
+
17
+ - Idle-reap now uses the pane-log (TUI render-stream) mtime as its activity proxy, not the
18
+ transcript file mtime. A live claude session re-touches its session `.jsonl` periodically
19
+ *without a real turn*, so the transcript proxy never crossed `idleSecs` and **claude peers
20
+ never idle-reaped** — the fleet piled up warm while codex peers (whose session file isn't
21
+ re-touched) reaped normally. The pane-log goes truly quiet at the prompt, so idle peers now
22
+ reap as intended. Pane-log primary; transcript fallback only when the pane-log is missing
23
+ (legacy supervisor); the launch ready-gate still uses the transcript.
24
+
25
+ ## [0.4.15] - 2026-06-21
26
+
27
+ ### Fixed
28
+
29
+ - Ephemeral idle-reap no longer kills a **working** session mid-turn. An armed ephemeral
30
+ worker (one that has sent its reply) was reaped after 20s of *transcript* silence, but the
31
+ transcript goes quiet during a long model generation — so a still-working worker was killed
32
+ mid-turn. The reap now folds in the pane-log (TUI render-stream) mtime, so "quiet" means the
33
+ turn truly ended: a working session is never reaped, while an idle one still reaps in ~20s
34
+ (keeping the ephemeral serial conveyor draining promptly).
35
+
36
+ ## [0.4.14] - 2026-06-21
37
+
38
+ ### Fixed
39
+
40
+ - **Completed the bot_username cutover.** The telegram sender-guard (`hasTelegramPresence`)
41
+ now keys on `interfaces.telegram.bot_username` (the cutover's binding key) instead of the
42
+ legacy `bot`; the host registry was regenerated from the per-cwd source of truth, dropping
43
+ the stale `interfaces.telegram.bot` field across the fleet. telegram-runtime already
44
+ resolved on `bot_username`, so the foundation guard was the last consumer of `bot`.
45
+ - `recordDrift` (verify / reconcile) now compares the `interfaces` passport with an
46
+ order-insensitive canonical compare, so a source↔derived divergence is detected and healed
47
+ by `verify --fix` — the class that had let the derived registry silently keep a field
48
+ removed from the source of truth.
49
+
13
50
  ### Changed
14
51
 
15
52
  - Fact-actualized the repo docs against the current code: pty-only (tmux references removed),
16
53
  no canary/forensics in postmortem diagnostics, and the legacy `runtime`-mirror drop.
54
+ - Unified the README to the fleet documentation style.
17
55
 
18
56
  ### Added
19
57
 
20
- - This changelog.
58
+ - This changelog (Keep a Changelog), shipped in the npm package.
59
+ - HD hero GIF in the README (1440×726 — sharper terminal text).
21
60
 
22
61
  ## [0.4.13] - 2026-06-20
23
62
 
@@ -206,7 +245,10 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
206
245
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
207
246
  (timer = cron, watcher = event) are all first-class and addressed the same way.
208
247
 
209
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.13...HEAD
248
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.16...HEAD
249
+ [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
250
+ [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
251
+ [0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
210
252
  [0.4.13]: https://github.com/agfpd/iapeer/compare/v0.4.12...v0.4.13
211
253
  [0.4.12]: https://github.com/agfpd/iapeer/compare/v0.4.11...v0.4.12
212
254
  [0.4.11]: https://github.com/agfpd/iapeer/compare/v0.4.10...v0.4.11
package/README.md CHANGED
@@ -20,18 +20,18 @@ Agents on Claude and on Codex are different AI runtimes from different vendors,
20
20
  ## How it works
21
21
 
22
22
  ```text
23
- send_to_peer(name, text) ← from any peer
23
+ send_to_peer(name, text) ← from any peer
24
24
  │
25
25
  ▼
26
- router daemon — always on; finds the recipient, checks liveness, delivers
26
+ router daemon — always on; finds the recipient, checks liveness, delivers
27
27
  │
28
28
  ▼
29
- recipient peer — asleep? the daemon wakes it and delivers on wake
29
+ recipient peer — asleep? the daemon wakes it and delivers on wake
30
30
  ```
31
31
 
32
32
  ## What makes it different
33
33
 
34
- - **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 (as of 2026-06-15, `claude -p` no longer counts against the subscription's main usage limits).
34
+ - **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.
35
35
  - **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.
36
36
  - **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.
37
37
  - **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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
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",
@@ -56,7 +56,7 @@ export function normalizeIntelligenceValue(value: unknown): Intelligence | undef
56
56
  return LEGACY_INTELLIGENCE[value]
57
57
  }
58
58
 
59
- const NATURAL_RUNTIMES = new Set(['telegram', 'discord', 'matrix', 'email', 'web'])
59
+ const NATURAL_RUNTIMES = new Set(['telegram', 'discord', 'matrix', 'email', 'web', 'voicetalk'])
60
60
  const ABSENT_RUNTIMES = new Set(['notifier', 'webhook', 'api', 'cron'])
61
61
 
62
62
  export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
@@ -67,12 +67,12 @@ export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
67
67
 
68
68
  // Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
69
69
  // warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
70
- // a property of the RUNTIME, not the personality (zone Идентичность). Both infra
71
- // runtimes are routers with a tmux endpoint: telegram receives inbound messages,
72
- // notifier receives send_to_peer(timer|watcher, …) registration/live-reload — so
73
- // each needs a live tmux pane for the daemon's deliverViaTmux to paste into. This
74
- // set gates always-on launchd plist generation (src/launch/launchd.ts).
75
- const INFRA_RUNTIMES = new Set(['notifier', 'telegram'])
70
+ // a property of the RUNTIME, not the personality (zone Идентичность). Infra runtimes
71
+ // are routers hosted by the pty supervisor: telegram/voicetalk carry a human channel
72
+ // (inbound messages / voice), notifier receives send_to_peer(timer|watcher, …)
73
+ // registration/live-reload — outbound to each is deliverHosted → the child's stdin.
74
+ // This set gates always-on launchd plist generation (src/launch/launchd.ts).
75
+ const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk'])
76
76
 
77
77
  export function isInfraRuntime(runtime: string): boolean {
78
78
  return INFRA_RUNTIMES.has(runtime)
@@ -89,6 +89,7 @@ export function isInfraRuntime(runtime: string): boolean {
89
89
  export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
90
90
  notifier: 'NOTIFIER_RUNTIME_BIN',
91
91
  telegram: 'TELEGRAM_RUNTIME_BIN',
92
+ voicetalk: 'VOICETALK_RUNTIME_BIN',
92
93
  }
93
94
 
94
95
  /** The PATH-resolvable default launcher name per infra runtime (when no abs path
@@ -97,6 +98,7 @@ export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
97
98
  export const INFRA_RUNTIME_DEFAULT_BIN: Readonly<Record<string, string>> = {
98
99
  notifier: 'notifier-runtime',
99
100
  telegram: 'telegram-runtime',
101
+ voicetalk: 'voicetalk-runtime',
100
102
  }
101
103
 
102
104
  // codex MCP token-free import (contract Установка §codex MCP). codex REFUSES to import
@@ -0,0 +1,95 @@
1
+ // voicetalk RuntimeAdapter — the "HOW to launch / observe ONE voicetalk session"
2
+ // half of the launch contract (src/launch/types.ts). Like telegram, voicetalk is a
3
+ // long-running ROUTER (voicetalk-runtime run: mic + wake-word listen → IAP envelopes
4
+ // to the fleet; <iap>-marked stdin → TTS to the owner's speaker), NOT an LLM TUI. So
5
+ // kind:'router' tells launch.launch to SKIP every TUI phase — no pane boot dialog, no
6
+ // ready-marker, no first-user-turn delivery, no activity-proxy ready-gate, no
7
+ // permission modal, no transcript to resume. The pane predicates below are therefore
8
+ // trivial constants, present only to satisfy the frozen interface.
9
+ //
10
+ // voicetalk is a presence-runtime for a HUMAN (the owner's voice channel), modelled on
11
+ // telegram per the foundation presence-runtime contract: kind:'router',
12
+ // usesDoctrine:false, requiresIntelligence:'natural', argv = `<bin> run <extra>`. The
13
+ // STT/TTS engines and providers live in a shared voice service (voice-connect), NOT in
14
+ // this adapter and NOT in the peer passport — interfaces.voicetalk carries only the
15
+ // binding ({wake_word, device}), opaque to the foundation.
16
+ //
17
+ // NO currency on this path: no marketplace check, no plugin install/update — that is
18
+ // install-time (the package self-deploys its runtime.json manifest), not session
19
+ // bring-up.
20
+
21
+ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // voicetalkAdapter
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ export const voicetalkAdapter: RuntimeAdapter = {
28
+ runtime: 'voicetalk',
29
+ kind: 'router',
30
+ usesDoctrine: false,
31
+
32
+ /** No submit surface — a router takes no bracketed-paste-then-Enter turn; delivery
33
+ * is deliverHosted → child stdin (<iap> reader), not submitIntoTui. Empty glyphs. */
34
+ deliveryMarkers: { promptGlyphs: [] },
35
+
36
+ /**
37
+ * voicetalk is a HUMAN channel — launch REFUSES a non-natural peer (fail-loud).
38
+ * Same nature gate as telegram: a peer with intelligence artificial/absent must never
39
+ * be brought up on voicetalk (it would route the owner's voice channel to an
40
+ * agent/script). The launch primitive enforces this against LaunchSpec.intelligence;
41
+ * source of intelligence → docs/Идентичность.
42
+ */
43
+ requiresIntelligence: 'natural',
44
+
45
+ /**
46
+ * argv = voicetalkBin + 'run' + extraArgs.
47
+ * - cfg.voicetalkBin ?? 'voicetalk-runtime' the launch binary (the always-on
48
+ * plist pins an absolute path via VOICETALK_RUNTIME_BIN — launchd-minimal PATH;
49
+ * here we defer to cfg.voicetalkBin when set, else emit the PATH-resolvable
50
+ * literal for launch.launch to resolve).
51
+ * - 'run' the router subcommand: reads the peer-profile binding (interfaces.voicetalk),
52
+ * brings up mic + wake-word listening, and pumps IAP envelopes both directions
53
+ * through the session's stdio (outbound <iap> stdin → TTS; inbound mic → fleet via
54
+ * the daemon send_to_peer).
55
+ * - ...extraArgs LaunchSpec.extraArgs passthrough.
56
+ *
57
+ * NO --system-prompt-file: usesDoctrine:false — a router is not an LLM, no doctrine to
58
+ * merge. NO --resume: a router has no transcript. NO currency on this path.
59
+ */
60
+ buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
61
+ return [cfg.voicetalkBin ?? 'voicetalk-runtime', 'run', ...(spec.extraArgs ?? [])]
62
+ },
63
+
64
+ /** No startup dialogs — a router has no pane TUI to answer. launch.launch skips the
65
+ * boot phase for kind:'router', so this is never consulted; null = "no dialog". */
66
+ bootDialogKeys(_pane: string): string[] | null {
67
+ return null
68
+ },
69
+
70
+ /** Always ready: a router has no input surface waiting for a first message — it is
71
+ * "up" the instant `voicetalk-runtime run` is launched. launch.launch skips the
72
+ * ready-gate for kind:'router'; true = "nothing to wait for". */
73
+ isInputReady(_pane: string): boolean {
74
+ return true
75
+ },
76
+
77
+ /** No activity proxy: a router writes no transcript/session jsonl to mtime as a
78
+ * ready-gate or idle signal. launch.launch skips the activity-proxy gate for a
79
+ * router; null per the contract ("null for a router (no proxy)"). */
80
+ newestActivityMtime(_cwd: string): number | null {
81
+ return null
82
+ },
83
+
84
+ /** Nothing to resume: a router does not replay a transcript, so resume cannot
85
+ * fail-loud the way claude/codex do. Always {ok:true}. */
86
+ resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
87
+ return { ok: true }
88
+ },
89
+
90
+ /** No in-session control: a router has no TUI turn to interrupt/compact. null for
91
+ * every command → the daemon/CLI surfaces an explicit "unsupported" refusal. */
92
+ executeControl(_command: ControlCommand): ControlPlan | null {
93
+ return null
94
+ },
95
+ }
@@ -23,6 +23,7 @@ import { claudeAdapter } from './adapters/claude.ts'
23
23
  import { codexAdapter } from './adapters/codex.ts'
24
24
  import { telegramAdapter } from './adapters/telegram.ts'
25
25
  import { notifierAdapter } from './adapters/notifier.ts'
26
+ import { voicetalkAdapter } from './adapters/voicetalk.ts'
26
27
  import type {
27
28
  LaunchConfig,
28
29
  LaunchFn,
@@ -88,6 +89,7 @@ export function getAdapter(runtime: Runtime): RuntimeAdapter {
88
89
  if (runtime === 'codex') return codexAdapter
89
90
  if (runtime === 'telegram') return telegramAdapter
90
91
  if (runtime === 'notifier') return notifierAdapter
92
+ if (runtime === 'voicetalk') return voicetalkAdapter
91
93
  throw new Error(`no launch adapter for runtime "${runtime}"`)
92
94
  }
93
95
 
@@ -96,6 +96,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
96
96
  // (installAlwaysOnPlist) uses — so the pin and the read can never drift.
97
97
  telegramBin: env[INFRA_RUNTIME_BIN_ENV.telegram],
98
98
  notifierBin: env[INFRA_RUNTIME_BIN_ENV.notifier],
99
+ voicetalkBin: env[INFRA_RUNTIME_BIN_ENV.voicetalk],
99
100
  sockDir,
100
101
  bootDeadlineSecs: 30,
101
102
  readyGateSecs: 30,
@@ -134,6 +134,8 @@ export interface LaunchAdapterConfig {
134
134
  telegramBin?: string
135
135
  /** notifier-runtime launch binary (router runtime, infra/always-on). */
136
136
  notifierBin?: string
137
+ /** voicetalk-runtime launch binary (router runtime, infra/always-on — the owner's voice channel). */
138
+ voicetalkBin?: string
137
139
  }
138
140
 
139
141
  export interface LaunchSpec {
@@ -12,7 +12,7 @@
12
12
  // before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
13
13
  // sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
14
14
 
15
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
16
16
  import { homedir } from 'os'
17
17
  import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
@@ -74,20 +74,21 @@ export interface LifecycleConfig {
74
74
  crashLoopWindowSecs: number
75
75
  /** wake_policy:ephemeral M2 — the quiet window (seconds of activity-proxy silence)
76
76
  * after which an ARMED ephemeral session (it has sent its outbound reply) is
77
- * reaped. Long enough for the post-reply housekeeping writes (operative notes)
78
- * to keep resetting it; far below idleSecs. ASSUMPTION (by design): the
79
- * transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
80
- * "done" signal. */
77
+ * reaped. Kept SHORT so the M3 serial conveyor drains the peer's next queued task
78
+ * promptly once its session is truly idle. "Quiet" here is BOTH the transcript proxy
79
+ * AND the pane-log (TUI render-stream) silent — the pane-log advances ~1s while the
80
+ * TUI renders a turn (generation OR a tool), so a still-working session is NEVER
81
+ * reaped mid-turn even though the transcript is momentarily quiet (the live incident
82
+ * this fixes: an armed worker generating its next step >20s was reaped at age=29s). */
81
83
  ephemeralQuietSecs: number
82
84
  /** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
83
85
  * that never armed (finished silently / lost its arm to a daemon-restart window)
84
- * is reaped after this much activity-proxy silence. Live case: a worker that
85
- * ended silently without its final outbound stalled its M3 FIFO for the FULL
86
- * generic idleSecs (1 h) — with serial drain that blocks the whole conveyor per
87
- * silent worker. Bound chosen ≫ the legitimate silent-tool case
88
- * (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
89
- * stays silent longer than this MID-TASK is reaped and its consumed queue item
90
- * is lost — ephemeral workers must emit activity (or their reply) within it. */
86
+ * is reaped after this much TRUE-idle silence (transcript AND pane-log both quiet).
87
+ * Live case: a worker that ended silently without its final outbound stalled its M3
88
+ * FIFO for the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
89
+ * conveyor per silent worker. Bound chosen ≫ a brief no-output gap and ≪ idleSecs.
90
+ * A still-WORKING silent-tool worker keeps the pane-log fresh via the TUI render, so it
91
+ * is no longer reaped mid-task — only a genuinely idle/wedged session crosses this. */
91
92
  ephemeralUnarmedIdleSecs: number
92
93
  }
93
94
 
@@ -343,6 +344,21 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
343
344
  }
344
345
  }
345
346
 
347
+ /** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
348
+ * render byte-stream the supervisor appends for the session's whole life. It advances
349
+ * ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
350
+ * during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
351
+ * the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
352
+ * typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
353
+ * a long model generation (no JSONL write until the message completes). null if absent. */
354
+ function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
355
+ try {
356
+ return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
357
+ } catch {
358
+ return null
359
+ }
360
+ }
361
+
346
362
  function deathsPath(cfg: LifecycleConfig, identity: string): string {
347
363
  return join(cfg.stateDir, `${identity}.deaths`)
348
364
  }
@@ -1147,6 +1163,9 @@ export interface SuperviseDeps {
1147
1163
  /** Liveness seam (default: the real `sessionAlive` = pty-host OR tmux-fallback). Tests inject a
1148
1164
  * fixed verdict so the idle path is exercised hermetically — no live tmux/pty session needed. */
1149
1165
  sessionAlive?: (sock: string, identity: string) => boolean
1166
+ /** Pane-log mtime seam (default: the real pane-log file mtime). Tests inject a controlled value to
1167
+ * exercise the ephemeral active-turn vs idle distinction without a real pane-log on disk. */
1168
+ paneLogMtime?: (identity: string) => number | null
1150
1169
  }
1151
1170
 
1152
1171
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
@@ -1154,6 +1173,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1154
1173
  const nowMs = deps.nowMs ?? Date.now()
1155
1174
  const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
1156
1175
  const isAlive = deps.sessionAlive ?? sessionAlive
1176
+ const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
1157
1177
  const verbose = superviseLogVerbose(env)
1158
1178
  // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1159
1179
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
@@ -1226,32 +1246,45 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1226
1246
  trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
1227
1247
  continue
1228
1248
  }
1229
- // Idle accounting via the runtime adapter's activity proxy (claude transcript
1230
- // / codex session mtime), FLOORED at wokeAt: a session cannot be idle longer than
1231
- // it has been awake. A wake that produced no model turn (e.g. an attach-only resume)
1232
- // leaves the proxy at its PRE-wake value; the old `?? wokeAt` floored only the NULL
1233
- // case, so a stale-but-non-null proxy (older than this wake) made a freshly-woken
1234
- // session look idle for its entire prior life → idle-reaped on the very next tick,
1235
- // out from under an attaching operator. max(), not ??, is the fix — and it still
1236
- // reaps a session that produced zero further turns (mt=wokeAt → reaped idleSecs
1237
- // after the wake), and falls back to wokeAt when no adapter exists for the runtime.
1238
- let mt = s.wokeAt
1239
- try {
1240
- mt = Math.max(activityMtime(s.runtime, s.cwd) ?? 0, s.wokeAt)
1241
- } catch {
1242
- /* no adapter for this runtime yet → wokeAt baseline */
1249
+ // Idle accounting via the PANE-LOG (TUI render-stream) mtime — the RELIABLE idle
1250
+ // signal: it advances ~1s while the TUI renders ANY turn (model generation OR a tool
1251
+ // run — the "esc to interrupt" elapsed counter ticks) and goes quiet ONLY at the
1252
+ // prompt. NOT the transcript file mtime: a live claude session RE-TOUCHES its session
1253
+ // .jsonl ~every 30-45min WITHOUT a real turn (a system/bridge re-save), so the
1254
+ // transcript proxy is perpetually < idleSecs and a claude peer NEVER idle-reaps — the
1255
+ // fleet-reap incident: a warm claude pile, last real turn hours ago, never reaped;
1256
+ // codex's session file is NOT re-touched, so codex reaps (the claude-vs-codex split).
1257
+ // The transcript stays the launch ready-gate's signal (it needs "a real turn produced
1258
+ // output"); only this idle proxy moves to the pane-log. FLOORED at wokeAt: a session
1259
+ // cannot be idle longer than it has been awake (a wake that produced no turn →
1260
+ // mt=wokeAt → reaped idleSecs after the wake, never out from under an attaching op).
1261
+ const ephemeral = isEphemeralPeer(s.cwd)
1262
+ const plMt = paneLogMt(s.identity)
1263
+ let mt: number
1264
+ if (plMt !== null) {
1265
+ mt = Math.max(plMt, s.wokeAt)
1266
+ } else {
1267
+ // Pane-log MISSING (a legacy pre-pane-log supervisor) → fall back to the transcript
1268
+ // proxy, NEVER to wokeAt-as-activity: a wokeAt-only age would read an ACTIVE legacy
1269
+ // session's idle from its hours-old wokeAt and reap it MID-WORK. The transcript is
1270
+ // false-fresh at idle (so an IDLE legacy peer lingers warm until its next respawn
1271
+ // earns a pane-log — bounded, self-heals), but an ACTIVE legacy peer keeps a
1272
+ // genuinely-fresh transcript and is correctly protected. Safe direction preserved.
1273
+ let tx = 0
1274
+ try { tx = activityMtime(s.runtime, s.cwd) ?? 0 } catch { /* no adapter → wokeAt */ }
1275
+ mt = Math.max(tx, s.wokeAt)
1243
1276
  }
1244
1277
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1245
1278
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1246
1279
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
1247
- // BEFORE the idle branch (quiet ≪ idle). Quiet = the activity proxy stayed
1248
- // silent for ephemeralQuietSecs — post-reply housekeeping (operative-note
1249
- // writes) keeps resetting it, so the worker finishes its bookkeeping first.
1250
- // NOT armed (still mid-task, e.g. a long silent tool run) → the ordinary
1251
- // idle bound below is its only reaper. Deliberate, policy-driven death:
1252
- // NO .idle-reaped (an ephemeral peer never resumes) and NO recordDeath
1253
- // (the crash-loop ring counts faults, not policy reaps).
1254
- if (isEphemeralPeer(s.cwd) && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1280
+ // BEFORE the idle branch (quiet ≪ idle). Quiet = the pane-log silent for
1281
+ // ephemeralQuietSecs (the reliable render signal above) — a still-rendering turn
1282
+ // keeps it fresh, so the worker finishes its turn first; post-reply housekeeping
1283
+ // (operative-note writes) also advances it.
1284
+ // NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
1285
+ // Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
1286
+ // and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
1287
+ if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1255
1288
  killSession(sock, s.identity)
1256
1289
  clearEphemeralArmed(cfg, s.identity)
1257
1290
  removeSessionState(cfg, s.identity)
@@ -1274,8 +1307,11 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1274
1307
  // silent-finish path is `iapeer self-done` (arm without waking anyone —
1275
1308
  // the invariant «нет пустых пробуждений» stays intact); this branch only
1276
1309
  // bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
1277
- // (ephemeral never resumes), NO recordDeath (the ring counts faults).
1278
- if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1310
+ // (ephemeral never resumes), NO recordDeath (the ring counts faults). The pane-log
1311
+ // fold-in above also covers this branch: a STILL-WORKING unarmed worker (a silent
1312
+ // tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
1313
+ // reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
1314
+ if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1279
1315
  killSession(sock, s.identity)
1280
1316
  clearEphemeralArmed(cfg, s.identity)
1281
1317
  removeSessionState(cfg, s.identity)
@@ -32,6 +32,7 @@ import { readRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from
32
32
  export const RUNTIME_PACKAGES: Readonly<Record<string, string>> = {
33
33
  telegram: '@agfpd/telegram-runtime',
34
34
  notifier: '@agfpd/notifier-runtime',
35
+ voicetalk: '@agfpd/voicetalk-runtime',
35
36
  }
36
37
 
37
38
  /** Resolve the npm package for a runtime: explicit override wins, else the built-in