@agfpd/iapeer 0.4.15 → 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,6 +10,18 @@ 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
+
13
25
  ## [0.4.15] - 2026-06-21
14
26
 
15
27
  ### Fixed
@@ -233,7 +245,8 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
233
245
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
234
246
  (timer = cron, watcher = event) are all first-class and addressed the same way.
235
247
 
236
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.15...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
237
250
  [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
238
251
  [0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
239
252
  [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.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 {
@@ -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