@agfpd/iapeer 0.4.15 → 0.4.17

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,6 +10,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.17] - 2026-06-21
14
+
15
+ ### Added
16
+
17
+ - `iapeer rename <old> <new>` — first-class peer-identity rename (parity with remove/create).
18
+ Renames the personality in **both** the registry and the per-cwd profile atomically and
19
+ **keeps the cwd**, so the claude transcript history (keyed by `realpath(cwd)`) survives.
20
+ Refuses a LIVE peer unless `--force` (a running session carries the old identity in its
21
+ env), and purges the old identity's lifecycle markers. Memory re-key (the personality-keyed
22
+ operativka folder + author/index attribution) is the memory provider's separate step;
23
+ native-memory is cwd-keyed and unaffected.
24
+
25
+ ## [0.4.16] - 2026-06-21
26
+
27
+ ### Fixed
28
+
29
+ - Idle-reap now uses the pane-log (TUI render-stream) mtime as its activity proxy, not the
30
+ transcript file mtime. A live claude session re-touches its session `.jsonl` periodically
31
+ *without a real turn*, so the transcript proxy never crossed `idleSecs` and **claude peers
32
+ never idle-reaped** — the fleet piled up warm while codex peers (whose session file isn't
33
+ re-touched) reaped normally. The pane-log goes truly quiet at the prompt, so idle peers now
34
+ reap as intended. Pane-log primary; transcript fallback only when the pane-log is missing
35
+ (legacy supervisor); the launch ready-gate still uses the transcript.
36
+
13
37
  ## [0.4.15] - 2026-06-21
14
38
 
15
39
  ### Fixed
@@ -233,7 +257,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
233
257
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
234
258
  (timer = cron, watcher = event) are all first-class and addressed the same way.
235
259
 
236
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.15...HEAD
260
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.17...HEAD
261
+ [0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
262
+ [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
237
263
  [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
238
264
  [0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
239
265
  [0.4.13]: https://github.com/agfpd/iapeer/compare/v0.4.12...v0.4.13
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.15",
3
+ "version": "0.4.17",
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
@@ -53,7 +53,7 @@ import { getAdapter } from '../launch/index.ts'
53
53
  import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
54
54
  import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
55
55
  import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
56
- import { readPeerProfile, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
56
+ import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
57
57
  import { peerProfilePath } from '../storage/index.ts'
58
58
  import {
59
59
  isConformant,
@@ -827,6 +827,61 @@ export async function removePeerCli(
827
827
  }
828
828
  }
829
829
 
830
+ // ─────────────────────────────────────────────────────────────────────────────
831
+ // rename — first-class peer-identity rename (parity with remove/create). Wraps
832
+ // renamePeer (registry + per-cwd profile, atomic, inside the lock) and KEEPS the
833
+ // cwd, so the claude transcript history (slug = realpath(cwd)) survives the rename.
834
+ // ─────────────────────────────────────────────────────────────────────────────
835
+
836
+ export interface RenameOutcome {
837
+ oldPersonality: string
838
+ newPersonality: string
839
+ action: 'renamed' | 'absent' | 'target-exists' | 'refused-live'
840
+ reason?: string
841
+ cwd?: string
842
+ purgedState?: string[]
843
+ }
844
+
845
+ /**
846
+ * Rename a peer's personality through renamePeer (registry + per-cwd profile, atomic;
847
+ * cwd KEPT — the transcript history keyed by slug=realpath(cwd) survives). Refuses a LIVE
848
+ * peer unless --force: a running session carries the OLD identity in its env
849
+ * (PEER_PERSONALITY, set at launch), so renaming under it desyncs the registry from the
850
+ * live process — stop it first, it respawns under the new identity. Purges the OLD
851
+ * identity-keyed lifecycle markers (remove-parity) so a future namesake can't inherit
852
+ * them. Does NOT move memory: the operativka folder + author/index attribution are
853
+ * personality-keyed (the memory provider re-keys them separately); native-memory is
854
+ * cwd-keyed and rides the kept cwd unchanged.
855
+ */
856
+ export async function renamePeerCli(
857
+ oldPersonality: string,
858
+ newPersonality: string,
859
+ opts: CliEnvOptions & { force?: boolean } = {},
860
+ ): Promise<RenameOutcome> {
861
+ const env = opts.env ?? process.env
862
+ const index = readPeersIndex({ env })
863
+ const peer = findPeer(index, oldPersonality)
864
+ if (!peer) return { oldPersonality, newPersonality, action: 'absent' }
865
+ if (findPeer(index, newPersonality)) {
866
+ return { oldPersonality, newPersonality, action: 'target-exists', reason: `"${newPersonality}" already exists` }
867
+ }
868
+ const cfg = loadLifecycleConfig(env)
869
+ if (!opts.force) {
870
+ const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
871
+ if (liveRt) {
872
+ return {
873
+ oldPersonality,
874
+ newPersonality,
875
+ action: 'refused-live',
876
+ reason: `"${oldPersonality}" is LIVE on ${liveRt} — its running session carries the old identity in its env; stop it first (\`iapeer stop ${oldPersonality}\`) or pass --force`,
877
+ }
878
+ }
879
+ }
880
+ await renamePeer(oldPersonality, newPersonality, { env })
881
+ const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
882
+ return { oldPersonality, newPersonality, action: 'renamed', cwd: peer.cwd, purgedState }
883
+ }
884
+
830
885
  // ─────────────────────────────────────────────────────────────────────────────
831
886
  // send — manual IAP send fallback (contract Примитивы §send). Goes through the
832
887
  // same router path as send_to_peer (resolve → deliver / wake), in-process so it
@@ -999,6 +1054,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
999
1054
  desc: 'LAZY soft-reload: agentic peer comes up FRESH (re-reads doctrine) on its NEXT natural wake — no kill, no burst-wake; non-agentic runtimes skipped. --all = whole fleet. (eager: `new`/self-fresh)',
1000
1055
  },
1001
1056
  { sig: 'remove <peer> [--force]', desc: 'delete a peer\'s registry record (locked writer); refuses a LIVE peer unless --force' },
1057
+ { sig: 'rename <old> <new> [--force]', desc: 'rename a peer\'s personality (registry + per-cwd profile, atomic; cwd/transcript-history kept); refuses a LIVE peer unless --force. Memory re-key is the provider\'s separate step.' },
1002
1058
  {
1003
1059
  sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
1004
1060
  desc: 'manual IAP send (fallback)',
@@ -1702,6 +1758,25 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1702
1758
  else errOut(`remove: ${o.reason}\n`)
1703
1759
  return o.action === 'refused-live' ? 1 : 0
1704
1760
  }
1761
+ case 'rename': {
1762
+ // Rename a peer's personality (registry + per-cwd profile, atomic; cwd KEPT so
1763
+ // the transcript history survives). Refuses a LIVE peer unless --force. Memory
1764
+ // (operativka folder + author/index) is personality-keyed and re-keyed separately
1765
+ // by the provider — say so, or it reads as a silent data loss.
1766
+ if (!positionals[0] || !positionals[1]) {
1767
+ return argErr(errOut, 'rename needs old and new names — usage: iapeer rename <old> <new> [--force]')
1768
+ }
1769
+ const o = await renamePeerCli(positionals[0], positionals[1], { force: flags.force === true, env })
1770
+ if (o.action === 'renamed') {
1771
+ out(`renamed "${o.oldPersonality}" → "${o.newPersonality}" (registry + per-cwd profile; cwd kept: ${o.cwd})\n`)
1772
+ if (o.purgedState?.length) out(`lifecycle state purged (old identity): ${o.purgedState.join(', ')}\n`)
1773
+ out(
1774
+ `NOTE: memory is NOT moved by rename — re-key the operativka folder + author/index attribution under "${o.newPersonality}" via the memory provider (native-memory rides the kept cwd, unchanged). Wake "${o.newPersonality}" to verify identity + history.\n`,
1775
+ )
1776
+ } else if (o.action === 'absent') errOut(`rename: "${o.oldPersonality}" not registered\n`)
1777
+ else errOut(`rename: ${o.reason}\n`)
1778
+ return o.action === 'renamed' ? 0 : 1
1779
+ }
1705
1780
  case 'send': {
1706
1781
  // Message body from EITHER --message <text> OR --message-file <f> (f='-' →
1707
1782
  // stdin). The runtime packages (telegram/notifier) + monitor deliver via
@@ -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 {
@@ -1246,42 +1246,41 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1246
1246
  trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
1247
1247
  continue
1248
1248
  }
1249
- // Idle accounting via the runtime adapter's activity proxy (claude transcript
1250
- // / codex session mtime), FLOORED at wokeAt: a session cannot be idle longer than
1251
- // it has been awake. A wake that produced no model turn (e.g. an attach-only resume)
1252
- // leaves the proxy at its PRE-wake value; the old `?? wokeAt` floored only the NULL
1253
- // case, so a stale-but-non-null proxy (older than this wake) made a freshly-woken
1254
- // session look idle for its entire prior life → idle-reaped on the very next tick,
1255
- // out from under an attaching operator. max(), not ??, is the fix — and it still
1256
- // reaps a session that produced zero further turns (mt=wokeAt → reaped idleSecs
1257
- // after the wake), and falls back to wokeAt when no adapter exists for the runtime.
1258
- let mt = s.wokeAt
1259
- try {
1260
- mt = Math.max(activityMtime(s.runtime, s.cwd) ?? 0, s.wokeAt)
1261
- } catch {
1262
- /* no adapter for this runtime yet → wokeAt baseline */
1263
- }
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).
1264
1261
  const ephemeral = isEphemeralPeer(s.cwd)
1265
- if (ephemeral) {
1266
- // FOLD IN the pane-log (TUI render-stream) mtime for ephemeral peers. The transcript
1267
- // proxy goes QUIET during a long model generation (no JSONL write until the message
1268
- // completes), so the short ephemeral windows below mis-read mid-generation as "done"
1269
- // and reap the worker MID-TURN (live incident 2026-06-20: Index reaped age=29s while
1270
- // generating the next step after a tool_result). The pane-log advances ~1s while the
1271
- // TUI renders a turn (model generation OR a tool run — the "esc to interrupt" elapsed
1272
- // counter ticks) and goes quiet only at the prompt, so it is the truer active-turn
1273
- // signal. max() means "quiet" iff BOTH are silent — it can only DELAY a reap, never
1274
- // cause an earlier one, so the pipeline serial-drain (idle → quiet → reaped) is
1275
- // preserved while a working session is never killed mid-turn.
1276
- const plMt = paneLogMt(s.identity)
1277
- if (plMt !== null) mt = Math.max(mt, plMt)
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)
1278
1276
  }
1279
1277
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1280
1278
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1281
1279
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
1282
- // BEFORE the idle branch (quiet ≪ idle). Quiet = transcript AND pane-log both
1283
- // silent for ephemeralQuietSecs — a still-rendering turn keeps the pane-log fresh,
1284
- // so the worker finishes its turn first; post-reply housekeeping also resets it.
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.
1285
1284
  // NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
1286
1285
  // Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
1287
1286
  // and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
@@ -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