@agfpd/iapeer 0.4.18 → 0.4.20

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,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.20] - 2026-06-22
14
+
15
+ ### Fixed
16
+
17
+ - **Codex hosted-delivery no longer false-fails.** The warm-delivery landing-confirm
18
+ (`deliverViaHost`) confirmed a message "landed" only by a transcript/session-jsonl mtime
19
+ advance within a 3000ms grace. Claude writes the user turn to its transcript promptly, but
20
+ **codex writes its session jsonl only at model-turn-start** (gated by time-to-first-token,
21
+ ~4-6s after submit) — so every codex delivery structurally false-failed with `"no transcript
22
+ advance within 3000ms"` even though the message landed and codex was processing it (proven in
23
+ delivery.log: an `ok=false` immediately followed by codex recording the input and replying).
24
+ The confirm now also accepts a **pane-log (TUI render-stream) mtime advance** — the same true
25
+ active-turn signal the 0.4.16 idle-reap fix uses; it ticks ~1s as the session renders the
26
+ working state on submit, giving codex delivery parity with claude without lengthening the
27
+ (synchronous) grace. An advance proves the session is alive and reacted to the just-flushed
28
+ bytes (a dead/wedged pty renders nothing → still fails correctly). The typing/tool-use
29
+ indicator for codex is a separate telegram-runtime concern (its pane-log parsing).
30
+
31
+ ## [0.4.19] - 2026-06-22
32
+
33
+ ### Added
34
+
35
+ - **Voice provider slot** (`@agfpd/voice-connect`) — a second declarative provider slot
36
+ alongside memory, integrated into the host lifecycle:
37
+ - `iapeer onboard` now offers an optional **DEFAULT-YES** voice step (after memory) in
38
+ both the linear path (`--no-voice` / `--voice <pkg>`) and the interactive wizard. It
39
+ installs the voice provider's **HOST BACKEND only** (a self-managed launchd HTTP
40
+ TTS/STT service) via the provider's own `init` (inherited stdio) — per-peer voice
41
+ tooling stays the separate explicit `iapeer enable voice-connect <peer>`. Report-only
42
+ for the exit code (an empty slot is a valid state).
43
+ - `iapeer update` cascades to the voice provider (best-effort, version-gated) via its
44
+ own `npm exec --package=@agfpd/voice-connect@latest -- voice-connect update` verb —
45
+ after foundation + runtimes + memory.
46
+ - `iapeer status` shows a `voice:` line (`<provider> <version> @ <endpoint>` + heartbeat
47
+ freshness, or `none`). The voice slot is read-only to the core, fail-open; unlike
48
+ memory it carries no provision/unprovision (voice is opt-in per-peer, not at birth)
49
+ and adds an `endpoint` for HTTP-facade discovery (telegram-STT / voicetalk).
50
+
13
51
  ## [0.4.18] - 2026-06-22
14
52
 
15
53
  ### Changed
@@ -274,7 +312,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
274
312
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
275
313
  (timer = cron, watcher = event) are all first-class and addressed the same way.
276
314
 
277
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.18...HEAD
315
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.20...HEAD
316
+ [0.4.20]: https://github.com/agfpd/iapeer/compare/v0.4.19...v0.4.20
317
+ [0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
278
318
  [0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
279
319
  [0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
280
320
  [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.18",
3
+ "version": "0.4.20",
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
@@ -1088,10 +1088,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1088
1088
  { sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
1089
1089
  { sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
1090
1090
  {
1091
- sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]',
1092
- desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
1091
+ sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--infra <csv>]',
1092
+ desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
1093
1093
  },
1094
- { sig: 'status', desc: 'host snapshot: version, daemon health, memory slot (<provider> | none)' },
1094
+ { sig: 'status', desc: 'host snapshot: version, daemon health, memory + voice slots (<provider> | none)' },
1095
1095
  { sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
1096
1096
  { sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
1097
1097
  { sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
@@ -1231,7 +1231,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1231
1231
  // Interactive TUI wizard (Фаза TUI-редизайн) — the DEFAULT for an
1232
1232
  // interactive `iapeer onboard`. Routed ONLY in a real interactive terminal
1233
1233
  // and never for an automation path (--accept-risk / --dry-run / --no-memory
1234
- // / --infra), which stay on the deterministic linear flow below. Escape
1234
+ // / --no-voice / --infra), which stay on the deterministic linear flow below. Escape
1235
1235
  // hatch: IAPEER_ONBOARD_WIZARD=0 forces the linear path (recovery / scripts
1236
1236
  // that want the old flow). The wizard owns its own gate screen, so we route
1237
1237
  // BEFORE the readline gate. It fails closed: no real TTY →
@@ -1241,6 +1241,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1241
1241
  flags['accept-risk'] === true ||
1242
1242
  flags['dry-run'] === true ||
1243
1243
  flags['no-memory'] === true ||
1244
+ flags['no-voice'] === true ||
1244
1245
  typeof flags.infra === 'string'
1245
1246
  if (!wizardOptOut && !automationFlag && process.stdin.isTTY === true && process.stdout.isTTY === true) {
1246
1247
  const { runOnboardWizard, WIZARD_NOT_INTERACTIVE } = await import('../tui/onboard/run.tsx')
@@ -1342,6 +1343,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1342
1343
  })
1343
1344
  const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
1344
1345
  out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
1346
+ // Voice slot (host backend only — per-peer voice tooling is the separate
1347
+ // `iapeer enable voice-connect <peer>`): optional DEFAULT-YES install of the
1348
+ // voice provider; its own init runs with INHERITED stdio (it owns the TTS-key
1349
+ // prompts). REPORT-ONLY for the exit code — an empty slot is a valid state.
1350
+ const { onboardVoiceProvider } = await import('../onboard/voice.ts')
1351
+ const voice = await onboardVoiceProvider({
1352
+ skip: flags['no-voice'] === true,
1353
+ package: typeof flags.voice === 'string' ? flags.voice : undefined,
1354
+ dryRun: flags['dry-run'] === true,
1355
+ env,
1356
+ })
1357
+ const voiceLabel = voice.provider ? `${voice.provider.provider} ${voice.provider.version}` : 'none'
1358
+ out(`voice: ${voice.state}${voice.detail ? ` — ${voice.detail}` : ''} (slot: ${voiceLabel})\n`)
1345
1359
  // Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
1346
1360
  // interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
1347
1361
  // CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
@@ -1945,9 +1959,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1945
1959
  // ── Cascade tail: runtimes + memory provider (best-effort) ──
1946
1960
  const { updateAllRuntimes } = await import('../runtime/update.ts')
1947
1961
  const { updateMemoryProvider } = await import('../onboard/memory.ts')
1962
+ const { updateVoiceProvider } = await import('../onboard/voice.ts')
1948
1963
  const tail = await cascadeTail({
1949
1964
  runtimes: () => updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) }),
1950
1965
  memory: () => updateMemoryProvider({ env }),
1966
+ voice: () => updateVoiceProvider({ env }),
1951
1967
  out,
1952
1968
  })
1953
1969
  return foundationExit === 0 && !tail.failed ? 0 : 1
@@ -0,0 +1,204 @@
1
+ // Voice-slot onboard step (the voice provider = @agfpd/voice-connect).
2
+ // Host-phase, optional, DEFAULT-YES: install the voice provider package and run ITS
3
+ // OWN init-verb — the provider deploys its HOST BACKEND (a self-managed launchd HTTP
4
+ // service), writes the slot declaration, and prompts for any TTS keys over our
5
+ // inherited tty. The core never writes the slot itself.
6
+ //
7
+ // SCOPE — host backend ONLY (the PIN, confirmed with voice-connect): init wires NO
8
+ // host MCP server. Per-peer voice tooling (the voice_create / tts MCP surface) is the
9
+ // SEPARATE explicit `iapeer enable voice-connect <peer>` (a marketplace capability
10
+ // plugin whose per-peer identity resolves from the MCP stdio child's cwd — a single
11
+ // host-wide server cannot resolve per-peer voices). So onboard ≠ per-peer wiring; the
12
+ // backend it installs is independently valuable (telegram-STT / voicetalk consume the
13
+ // HTTP facade with no MCP at all). Unlike memory there is therefore NO per-peer birth
14
+ // hook and the slot carries NO provision/unprovision.
15
+ //
16
+ // Outcome semantics: this step NEVER fails the onboard exit code — an empty slot is a
17
+ // fully valid state regardless of why (skipped / not yet published / non-tty refusal).
18
+ // Outcomes are reported, not enforced. Mirrors onboard/memory.ts deliberately (same
19
+ // vocabulary), minus memory's --human/--runtime init args (voice init takes none).
20
+
21
+ import { spawnSync } from 'child_process'
22
+ import { readVoiceProvider, type VoiceProvider } from '../status/index.ts'
23
+
24
+ /** The distribution default voice provider. */
25
+ export const DEFAULT_VOICE_PACKAGE = '@agfpd/voice-connect'
26
+
27
+ export interface VoiceOnboardOptions {
28
+ /** --no-voice: skip the step entirely. */
29
+ skip?: boolean
30
+ /** --voice <pkg>: override the provider package (default @agfpd/voice-connect). */
31
+ package?: string
32
+ dryRun?: boolean
33
+ env?: NodeJS.ProcessEnv
34
+ /** Wall-clock backstop (ms) for the provider-init subprocess — a hung provider must
35
+ * NEVER wedge onboard (voice is optional). The interactive wizard passes a generous
36
+ * value; the linear path leaves it unset (no timeout). */
37
+ timeoutMs?: number
38
+ /** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init`
39
+ * with inherited stdio. */
40
+ runInit?: (
41
+ pkg: string,
42
+ args: string[],
43
+ env: NodeJS.ProcessEnv,
44
+ timeoutMs?: number,
45
+ ) => { status: number | null; unavailable: boolean }
46
+ }
47
+
48
+ export interface VoiceOnboardResult {
49
+ state:
50
+ | 'installed' // provider init succeeded and declared the slot
51
+ | 'already' // slot already occupied by the SAME package — idempotent no-op
52
+ | 'skipped-flag' // --no-voice
53
+ | 'skipped-unavailable' // package not published / no network — soft skip (contract)
54
+ | 'refused-foreign' // slot occupied by ANOTHER provider — explicit refusal
55
+ | 'provider-init-failed' // provider init ran and exited non-zero / declared nothing
56
+ | 'dry-run'
57
+ provider: VoiceProvider | null
58
+ detail?: string
59
+ }
60
+
61
+ function defaultRunInit(
62
+ pkg: string,
63
+ args: string[],
64
+ env: NodeJS.ProcessEnv,
65
+ timeoutMs?: number,
66
+ ): { status: number | null; unavailable: boolean } {
67
+ // Availability probe FIRST (cheap, side-effect-free, bounded): an unpublished package
68
+ // / no network → soft-skip per the contract (the provider's release order must never
69
+ // block the core's onboard).
70
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
71
+ if (probe.status !== 0) return { status: probe.status, unavailable: true }
72
+ // INHERITED stdio — the provider owns its install questions (TTS-key prompts happen
73
+ // here, once). The provider bin (`voice-connect`) is an ARG-DISPATCHER: a subcommand
74
+ // (`init`) routes to its lifecycle path, NOT the backward-compatible MCP-stdio path
75
+ // (no-subcommand) — so `npx -y <pkg> init` correctly hits init.
76
+ const r = spawnSync('npx', ['-y', pkg, ...args], {
77
+ stdio: 'inherit',
78
+ env,
79
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
80
+ })
81
+ return { status: r.status, unavailable: false }
82
+ }
83
+
84
+ export async function onboardVoiceProvider(opts: VoiceOnboardOptions = {}): Promise<VoiceOnboardResult> {
85
+ const env = opts.env ?? process.env
86
+ const pkg = opts.package?.trim() || DEFAULT_VOICE_PACKAGE
87
+ const existing = readVoiceProvider(env)
88
+ if (opts.skip) return { state: 'skipped-flag', provider: existing }
89
+ if (existing) {
90
+ if (existing.package === pkg) return { state: 'already', provider: existing }
91
+ // Never silently install over an occupied slot (contract: FORBIDDEN).
92
+ return {
93
+ state: 'refused-foreign',
94
+ provider: existing,
95
+ detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
96
+ }
97
+ }
98
+ // voice init takes NO core-known facts (no --human/--runtime — the host backend is
99
+ // peer-agnostic; per-peer voice is a separate `iapeer enable`).
100
+ const args = ['init']
101
+ if (opts.dryRun) {
102
+ return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
103
+ }
104
+ const run = opts.runInit ?? defaultRunInit
105
+ const r = run(pkg, args, env, opts.timeoutMs)
106
+ if (r.unavailable) {
107
+ return {
108
+ state: 'skipped-unavailable',
109
+ provider: null,
110
+ detail: `${pkg} is not available (not published yet / no network) — install later: npx ${pkg} init`,
111
+ }
112
+ }
113
+ if (r.status !== 0) {
114
+ return {
115
+ state: 'provider-init-failed',
116
+ provider: readVoiceProvider(env),
117
+ detail: `provider init exited ${r.status ?? 'null'}`,
118
+ }
119
+ }
120
+ // The PROVIDER declares the slot; verify it actually did (its contract duty).
121
+ const after = readVoiceProvider(env)
122
+ if (!after) {
123
+ return {
124
+ state: 'provider-init-failed',
125
+ provider: null,
126
+ detail: 'provider init succeeded but did not declare the slot (voice-provider.json missing)',
127
+ }
128
+ }
129
+ return { state: 'installed', provider: after }
130
+ }
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // updateVoiceProvider — the voice leg of the cascade `iapeer update` (FU12)
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ export interface VoiceUpdateResult {
137
+ state:
138
+ | 'updated' // provider update ran and the slot version advanced
139
+ | 'already-latest' // ran but the slot version is unchanged
140
+ | 'no-slot' // no provider claimed — nothing to update
141
+ | 'skipped-unavailable' // package not published / no network — soft skip
142
+ | 'failed' // provider update exited non-zero
143
+ package?: string
144
+ from?: string
145
+ to?: string
146
+ detail?: string
147
+ }
148
+
149
+ /** The provider's CLI bin name = the unscoped package name (`@agfpd/voice-connect` →
150
+ * `voice-connect`). That bin is the arg-dispatcher; `<bin> update` routes to its
151
+ * lifecycle update path. */
152
+ function voiceBinName(pkg: string): string {
153
+ return pkg.replace(/^@[^/]+\//, '')
154
+ }
155
+
156
+ function defaultRunVoiceUpdate(pkg: string, env: NodeJS.ProcessEnv): { status: number | null; unavailable: boolean } {
157
+ // Availability probe FIRST (cheap, bounded): unpublished / no network → soft-skip.
158
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
159
+ if (probe.status !== 0) return { status: probe.status, unavailable: true }
160
+ // `npm exec --package=<pkg>@latest -- <bin> update` fetches the latest published
161
+ // package into a fresh temp and runs ITS update verb from there — bypassing any
162
+ // PATH name-shadow (the 0.2.9 pitfall: a bare `npx <bin>` could re-run a stale
163
+ // installed binary). voice-connect's update then re-copies its runtime tree into the
164
+ // STABLE provider home (~/.iapeer/providers/voice-connect/, never the GC'd npx cache),
165
+ // bootout→bootstraps its HTTP service, and bumps slot.version — so the before/after
166
+ // version-gate this leg reads is real. The provider OWNS update + slot-write + its own
167
+ // service restart.
168
+ const r = spawnSync(
169
+ 'npm',
170
+ ['exec', '--yes', `--package=${pkg}@latest`, '--', voiceBinName(pkg), 'update'],
171
+ { stdio: 'inherit', env },
172
+ )
173
+ return { status: r.status, unavailable: false }
174
+ }
175
+
176
+ /**
177
+ * Update the CLAIMED voice provider via ITS OWN update verb (slot contract: the
178
+ * provider owns install/update + slot-write + service restart; the core only invokes +
179
+ * reads the slot). No slot → nothing to update. Reports from→to by reading the slot
180
+ * version before/after. BEST-EFFORT by the caller (the cascade never aborts on it).
181
+ */
182
+ export function updateVoiceProvider(opts: {
183
+ env?: NodeJS.ProcessEnv
184
+ dryRun?: boolean
185
+ runUpdate?: (pkg: string, env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
186
+ } = {}): VoiceUpdateResult {
187
+ const env = opts.env ?? process.env
188
+ const slot = readVoiceProvider(env)
189
+ if (!slot) return { state: 'no-slot', detail: 'no voice provider claimed — nothing to update' }
190
+ const pkg = slot.package
191
+ const from = slot.version
192
+ if (opts.dryRun) {
193
+ return { state: 'updated', package: pkg, from, detail: `would run: npm exec --package=${pkg}@latest -- ${voiceBinName(pkg)} update` }
194
+ }
195
+ const run = opts.runUpdate ?? defaultRunVoiceUpdate
196
+ const r = run(pkg, env)
197
+ if (r.unavailable) {
198
+ return { state: 'skipped-unavailable', package: pkg, from, detail: `${pkg}@latest not reachable (no network / unpublished)` }
199
+ }
200
+ if (r.status !== 0) return { state: 'failed', package: pkg, from, detail: `provider update exited ${r.status ?? 'null'}` }
201
+ const to = readVoiceProvider(env)?.version
202
+ if (from && to && from === to) return { state: 'already-latest', package: pkg, from, to }
203
+ return { state: 'updated', package: pkg, from, to }
204
+ }
@@ -1,10 +1,14 @@
1
1
  // Status — the host-snapshot verb: installed-binary version, daemon health, and
2
- // the MEMORY SLOT line (docs/Слот памяти — контракт memory provider.md). The slot
3
- // is DECLARATIVE: a root file `~/.iapeer/memory-provider.json` written (atomically)
4
- // by the PROVIDER's own init/uninstall — the core only ever READS it. An absent or
5
- // unreadable file is the EMPTY slot — a fully valid state (bare core), never an
6
- // error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
7
- // it (healing the provider's daemon is the provider's job).
2
+ // the provider-SLOT lines (docs/Слот памяти — контракт memory provider.md). A slot
3
+ // is DECLARATIVE: a root file (`~/.iapeer/memory-provider.json`, `voice-provider.json`)
4
+ // written (atomically) by the PROVIDER's own init/uninstall — the core only ever
5
+ // READS it. An absent or unreadable file is the EMPTY slot — a fully valid state
6
+ // (bare core), never an error (fail-open). The core never acts on heartbeat
7
+ // staleness — it only REPORTS it (healing the provider's daemon is the provider's
8
+ // job). Two slots share the same declarative contract: MEMORY (auto-at-birth, carries
9
+ // provision/unprovision) and VOICE (host-level backend only; per-peer voice tooling is
10
+ // the separate `iapeer enable voice-connect <peer>` — the voice slot carries NO
11
+ // provision/unprovision, and adds an `endpoint` for HTTP-facade discovery).
8
12
 
9
13
  import { closeSync, openSync, readFileSync, statSync } from 'fs'
10
14
  import { join } from 'path'
@@ -97,8 +101,9 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
97
101
  }
98
102
  }
99
103
 
100
- /** Age (s) of the provider's heartbeat file, or null (none declared / unreadable). */
101
- export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.now()): number | null {
104
+ /** Age (s) of a provider's heartbeat file, or null (none declared / unreadable).
105
+ * Generic over any slot carrying an optional `heartbeat` path (memory + voice). */
106
+ export function heartbeatAgeSecs(provider: { heartbeat?: string }, nowMs: number = Date.now()): number | null {
102
107
  if (!provider.heartbeat) return null
103
108
  try {
104
109
  return Math.max(0, Math.floor((nowMs - statSync(provider.heartbeat).mtimeMs) / 1000))
@@ -107,6 +112,63 @@ export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.
107
112
  }
108
113
  }
109
114
 
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // VOICE slot — the voice-connect provider declaration (the same declarative
117
+ // contract as memory, two differences by design: NO provision/unprovision (voice
118
+ // is opt-in PER-PEER via `iapeer enable`, not auto-at-birth), and an `endpoint`
119
+ // (+ a named `routes` object the core does NOT parse) for the HTTP-facade that
120
+ // telegram-STT / voicetalk consume with no MCP at all). The core only READS it.
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ /** The voice-slot declaration filename in the storage root. */
124
+ export const VOICE_PROVIDER_FILE = 'voice-provider.json'
125
+
126
+ export interface VoiceProvider {
127
+ /** Provider name occupying the slot (e.g. "voice-connect"). */
128
+ provider: string
129
+ /** npm package of the provider (e.g. "@agfpd/voice-connect"). */
130
+ package: string
131
+ version: string
132
+ registeredAt: string
133
+ /** Optional liveness proxy: an absolute path whose mtime the provider's HTTP
134
+ * daemon refreshes. status reports its age; the core takes NO action on staleness. */
135
+ heartbeat?: string
136
+ /** Base URL of the provider's HTTP facade (e.g. "http://127.0.0.1:PORT") — shown
137
+ * in status for discovery. Opaque to the core. */
138
+ endpoint?: string
139
+ }
140
+
141
+ export function voiceProviderPath(env: NodeJS.ProcessEnv = process.env): string {
142
+ return join(resolveGlobalRoot(env), VOICE_PROVIDER_FILE)
143
+ }
144
+
145
+ /**
146
+ * Read the voice-slot declaration. null = EMPTY slot (absent / unreadable /
147
+ * schema-invalid file) — a valid state, so this NEVER throws (fail-open to bare).
148
+ * Self-management extras the provider may add (label/managed/host/port/routes) are
149
+ * ignored — the core reads only the contract fields it acts/reports on.
150
+ */
151
+ export function readVoiceProvider(env: NodeJS.ProcessEnv = process.env): VoiceProvider | null {
152
+ try {
153
+ const raw = JSON.parse(readFileSync(voiceProviderPath(env), 'utf8')) as unknown
154
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
155
+ const o = raw as Record<string, unknown>
156
+ if (typeof o.provider !== 'string' || !o.provider.trim()) return null
157
+ if (typeof o.package !== 'string' || !o.package.trim()) return null
158
+ if (typeof o.version !== 'string' || !o.version.trim()) return null
159
+ return {
160
+ provider: o.provider.trim(),
161
+ package: o.package.trim(),
162
+ version: o.version.trim(),
163
+ registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
164
+ ...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
165
+ ...(typeof o.endpoint === 'string' && o.endpoint.trim() ? { endpoint: o.endpoint.trim() } : {}),
166
+ }
167
+ } catch {
168
+ return null // empty slot — bare core is valid
169
+ }
170
+ }
171
+
110
172
  /**
111
173
  * Probe whether the iapeer binary holds Full Disk Access (macOS TCC). Reads the
112
174
  * user TCC.db — a canonical FDA-gated resource that is NEVER promptable (silent
@@ -137,6 +199,7 @@ export interface HostStatus {
137
199
  version: string
138
200
  daemon: { healthy: boolean; url: string | null; sock: string | null }
139
201
  memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
202
+ voice: { provider: VoiceProvider | null; heartbeatAgeSecs: number | null }
140
203
  /** Full Disk Access of the iapeer binary: true granted / false not-granted /
141
204
  * null undeterminable (non-macOS or TCC.db unreadable for a non-TCC reason). */
142
205
  fda: boolean | null
@@ -165,11 +228,13 @@ export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStat
165
228
  }
166
229
  const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
167
230
  const provider = readMemoryProvider(env)
231
+ const voice = readVoiceProvider(env)
168
232
  const fda = (opts.fdaProbe ?? (() => probeFullDiskAccess(env)))()
169
233
  return {
170
234
  version: IAPEER_VERSION,
171
235
  daemon: { healthy: health.healthy, url, sock },
172
236
  memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
237
+ voice: { provider: voice, heartbeatAgeSecs: voice ? heartbeatAgeSecs(voice) : null },
173
238
  fda,
174
239
  }
175
240
  }
@@ -190,6 +255,17 @@ export function formatHostStatus(s: HostStatus): string {
190
255
  : ' (daemon not running — no heartbeat file)'
191
256
  }
192
257
  const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
258
+ // Voice slot — same heartbeat semantics as memory; also show the HTTP endpoint
259
+ // (discovery target for telegram-STT / voicetalk). 'none' when the slot is empty.
260
+ let vhb = ''
261
+ if (s.voice.provider?.heartbeat) {
262
+ vhb = s.voice.heartbeatAgeSecs !== null
263
+ ? ` (heartbeat ${s.voice.heartbeatAgeSecs}s ago)`
264
+ : ' (daemon not running — no heartbeat file)'
265
+ }
266
+ const voice = s.voice.provider
267
+ ? `${s.voice.provider.provider} ${s.voice.provider.version}${s.voice.provider.endpoint ? ` @ ${s.voice.provider.endpoint}` : ''}${vhb}`
268
+ : 'none'
193
269
  // FDA line: granted → terse OK; NOT granted → the actionable hint (a fresh host
194
270
  // without FDA silently meets TCC prompts on every new protected class — peers
195
271
  // read protected folders + foreign app containers, attributed to this binary;
@@ -204,5 +280,5 @@ export function formatHostStatus(s: HostStatus): string {
204
280
  } else {
205
281
  fda = 'fda: unknown (not macOS / undeterminable)'
206
282
  }
207
- return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n${fda}\n`
283
+ return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\nvoice: ${voice}\n${fda}\n`
208
284
  }
@@ -33,6 +33,7 @@ import {
33
33
  type ProcessAddress,
34
34
  } from '../core/socket.ts'
35
35
  import { buildEnvelope } from '../codec/index.ts'
36
+ import { resolveGlobalRoot } from '../storage/index.ts'
36
37
  import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
37
38
  import type { ResolvedCaller } from '../identity/index.ts'
38
39
  // Delivery markers are OWNED by the runtime adapter. transport
@@ -185,6 +186,12 @@ export interface WarmDeliverSeam {
185
186
  deliverHosted?: (identity: string, envelope: string) => Promise<DeliverResult>
186
187
  /** Activity proxy for the landed-confirm. Default: the target runtime adapter's transcript mtime. */
187
188
  newestActivityMtime?: (cwd: string) => number | null
189
+ /** SECOND landed-confirm proxy: the pane-log (TUI render-stream) mtime, keyed by process
190
+ * ADDRESS. Default: the real `<root>/logs/lifecycle/<address>.log` mtime. The pane-log ticks
191
+ * ~1s as the session renders its working state on submit — for codex it advances ~1s while the
192
+ * session-jsonl only advances at model-turn-start (~4-6s, past the grace), so this is the signal
193
+ * that gives codex delivery parity. See `deliverViaHost`. */
194
+ paneLogMtime?: (address: string) => number | null
188
195
  }
189
196
 
190
197
  /**
@@ -211,9 +218,12 @@ async function deliverViaHost(
211
218
  ): Promise<Result<void>> {
212
219
  const adapter = getAdapter(target.runtime)
213
220
  const mtimeOf = seam.newestActivityMtime ?? ((c: string) => adapter.newestActivityMtime(c))
214
- // Baseline the activity proxy BEFORE delivery (a paste/CR does not move the transcript — only a
215
- // model turn does), so an advance afterwards proves the session is alive and took our message.
221
+ const paneMtimeOf = seam.paneLogMtime ?? ((addr: string) => defaultPaneLogMtime(addr))
222
+ // Baseline BOTH landed-proxies BEFORE delivery (a paste/CR does not move the transcript — only a
223
+ // model turn does; the pane-log moves as the session renders), so an advance in EITHER afterwards
224
+ // proves the session is alive and took our message.
216
225
  const baseline = cwd ? mtimeOf(cwd) ?? 0 : 0
226
+ const paneBaseline = paneMtimeOf(target.address) ?? 0
217
227
  const send = seam.deliverHosted ?? deliverHosted
218
228
  const sent = await send(target.address, envelope)
219
229
  if (!sent.ok) {
@@ -232,22 +242,43 @@ async function deliverViaHost(
232
242
  // socket-ack ≠ landed. With no activity proxy (direct callers/tests without a cwd) we can only
233
243
  // trust the flushed submit — confirmed-only, mirroring deliverViaTmux's legacy-caller path.
234
244
  if (!cwd) return ok(undefined)
235
- // Confirm landing by the SAME final arbiter as the tmux busy path: a transcript-mtime advance.
236
- // Grace-poll the proxy (the model may take a beat to write its turn); a hosted session has no
237
- // input-clear fast path (no capture-pane), so this is the SOLE confirm. Prefer a false-FAIL
238
- // (sender retries) over a false-OK (silent loss, forbidden).
245
+ // Confirm landing by EITHER of two proxies within the grace:
246
+ // (a) transcript/session-jsonl mtime advance — the strong "model wrote a turn" signal. claude
247
+ // writes the user turn to its transcript promptly (sub-second); CODEX writes its session
248
+ // jsonl only at model-turn-START (gated by TTFT, measured ~4-6s after submit), so on the
249
+ // transcript-ONLY confirm every codex delivery STRUCTURALLY false-FAILED (the 3000ms grace
250
+ // expires before the first session write — proven in delivery.log).
251
+ // (b) PANE-LOG (TUI render-stream) mtime advance — the SAME true active-turn signal the 0.4.16
252
+ // idle-reap fix uses: it ticks ~1s as the session renders the working state on submit, so it
253
+ // catches codex in ~1s, giving codex delivery parity with claude WITHOUT lengthening the
254
+ // (synchronous) grace. NOT a false-OK: an advance proves the session is alive and rendered
255
+ // output in response to our just-flushed bytes (a wedged/dead pty renders nothing → no
256
+ // advance → correct fail); the bytes are then in the session's input (codex queues input
257
+ // during a turn). Prefer a false-FAIL (sender retries) over a false-OK (silent loss).
239
258
  const graceMs = hostLivenessGraceMs()
240
259
  const graceDeadline = monotonicMs() + graceMs
241
260
  do {
242
261
  if ((mtimeOf(cwd) ?? 0) > baseline) return ok(undefined)
262
+ if ((paneMtimeOf(target.address) ?? 0) > paneBaseline) return ok(undefined)
243
263
  sleepSync(LIVENESS_POLL_MS)
244
264
  } while (monotonicMs() < graceDeadline)
245
265
  return err(
246
266
  `hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
247
- `(no transcript advance within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
267
+ `(no transcript or pane-log advance within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
248
268
  )
249
269
  }
250
270
 
271
+ /** Pane-log (TUI render-stream) mtime for a process address, or null when absent/unreadable.
272
+ * The pane-log is the canonical `<root>/logs/lifecycle/<address>.log` the supervisor appends raw
273
+ * pty bytes to (§1 contract). Used as the SECOND landed-confirm proxy in deliverViaHost. */
274
+ function defaultPaneLogMtime(address: string): number | null {
275
+ try {
276
+ return statSync(join(resolveGlobalRoot(), 'logs', 'lifecycle', `${address}.log`)).mtimeMs
277
+ } catch {
278
+ return null
279
+ }
280
+ }
281
+
251
282
 
252
283
  // ─── Compact done gate (dialogue control must report FACT, not keystroke delivery) ──
253
284
 
@@ -18,6 +18,7 @@ import { iapeerBinPath } from '../../install/index.ts'
18
18
  export interface WizardResult {
19
19
  code: number
20
20
  memoryConsent: boolean
21
+ voiceConsent: boolean
21
22
  advisories: string[]
22
23
  summary: string[]
23
24
  telegramConsent: boolean
@@ -27,7 +28,7 @@ export interface WizardResult {
27
28
  hostRuntime?: OnboardRuntime
28
29
  }
29
30
 
30
- type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'declined' | 'finishing'
31
+ type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'declined' | 'finishing'
31
32
  type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
32
33
  interface Step {
33
34
  key: string
@@ -96,16 +97,17 @@ export function OnboardApp({
96
97
  const [advisories, setAdvisories] = useState<string[]>([])
97
98
  const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
98
99
  const [telegramConsent, setTelegramConsent] = useState(false)
100
+ const [memoryConsent, setMemoryConsent] = useState(false)
99
101
  const spin = useSpinnerFrame(phase === 'running')
100
102
 
101
- const finish = (memoryConsent: boolean): void => {
103
+ const finish = (voiceConsent: boolean): void => {
102
104
  const failed = steps.some(s => s.status === 'fail')
103
105
  const summary = steps.map(s => {
104
106
  const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
105
107
  return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
106
108
  })
107
109
  summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
108
- onResult({ code: failed ? 1 : 0, memoryConsent, telegramConsent, advisories, summary, hostRuntime })
110
+ onResult({ code: failed ? 1 : 0, memoryConsent, voiceConsent, telegramConsent, advisories, summary, hostRuntime })
109
111
  setPhase('finishing')
110
112
  setTimeout(() => exit(), 30)
111
113
  }
@@ -135,19 +137,34 @@ export function OnboardApp({
135
137
  { isActive: phase === 'telegram' },
136
138
  )
137
139
 
138
- // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
140
+ // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install). Records the decision
141
+ // and advances to the voice consent (the install itself runs post-Ink). Then → voice.
142
+ useInput(
143
+ (input, key) => {
144
+ if (input === 'n' || input === 'N') {
145
+ setMemoryConsent(false)
146
+ setPhase('voice')
147
+ } else if (input === 'y' || input === 'Y' || key.return) {
148
+ setMemoryConsent(true)
149
+ setPhase('voice')
150
+ }
151
+ },
152
+ { isActive: phase === 'memory' },
153
+ )
154
+
155
+ // Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install). The LAST consent → finish.
139
156
  useInput(
140
157
  (input, key) => {
141
158
  if (input === 'n' || input === 'N') finish(false)
142
159
  else if (input === 'y' || input === 'Y' || key.return) finish(true)
143
160
  },
144
- { isActive: phase === 'memory' },
161
+ { isActive: phase === 'voice' },
145
162
  )
146
163
 
147
164
  // Declined → exit 1 (no host mutation).
148
165
  useEffect(() => {
149
166
  if (phase !== 'declined') return
150
- onResult({ code: 1, memoryConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
167
+ onResult({ code: 1, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
151
168
  const t = setTimeout(() => exit(), 30)
152
169
  return () => clearTimeout(t)
153
170
  }, [phase, onResult, exit])
@@ -305,7 +322,7 @@ export function OnboardApp({
305
322
  return <Text color="red">onboard aborted — risk not accepted.</Text>
306
323
  }
307
324
 
308
- // running | telegram | memory | finishing
325
+ // running | telegram | memory | voice | finishing
309
326
  return (
310
327
  <Box flexDirection="column">
311
328
  <Box marginBottom={1}>
@@ -352,6 +369,14 @@ export function OnboardApp({
352
369
  <Text dimColor> Gives your agents a shared knowledge vault. [Y/n]</Text>
353
370
  </Box>
354
371
  ) : null}
372
+ {phase === 'voice' ? (
373
+ <Box marginTop={1} flexDirection="column">
374
+ <Text bold color="cyan">
375
+ ? Install the voice provider backend (@agfpd/voice-connect)?
376
+ </Text>
377
+ <Text dimColor> A local text-to-speech / speech-to-text HTTP service. Per-agent voice tools are a separate `iapeer enable`. [Y/n]</Text>
378
+ </Box>
379
+ ) : null}
355
380
  </Box>
356
381
  )
357
382
  }
@@ -71,7 +71,7 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
71
71
  // wizard only runs on one), off under NO_COLOR.
72
72
  const a = makeAnsi(colorEnabled(env, process.stdout))
73
73
 
74
- let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
74
+ let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: [] }
75
75
  try {
76
76
  const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
77
77
  await app.waitUntilExit()
@@ -79,9 +79,10 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
79
79
  return WIZARD_NOT_INTERACTIVE
80
80
  }
81
81
 
82
- // Post-Ink interactive provisioning — telegram THEN memory (the canonical order:
83
- // telegram creates the human peer that memory's --human resolves from). These own
84
- // the terminal (inherited stdio / readline), so they run AFTER Ink unmounts (no
82
+ // Post-Ink interactive provisioning — telegram THEN memory THEN voice (the canonical
83
+ // order: telegram creates the human peer that memory's --human resolves from; voice is
84
+ // an independent host backend). These own the terminal (inherited stdio / readline),
85
+ // so they run AFTER Ink unmounts (no
85
86
  // contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
86
87
  // CURRENT step's child/readline and onboard continues to the summary, instead of
87
88
  // the whole flow dying. The no-op handler is queued past the blocking spawnSync.
@@ -136,6 +137,33 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
136
137
  '\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
137
138
  )
138
139
  }
140
+
141
+ if (result.voiceConsent) {
142
+ const { onboardVoiceProvider } = await import('../../onboard/voice.ts')
143
+ process.stdout.write(
144
+ '\n' +
145
+ a.bold('Installing the voice provider backend (@agfpd/voice-connect)…') +
146
+ '\n' +
147
+ a.dim(
148
+ 'A local TTS/STT HTTP service. Voice is OPTIONAL: press Ctrl-C to skip it\n' +
149
+ '(set it up later with `npx @agfpd/voice-connect init`).',
150
+ ) +
151
+ '\n\n',
152
+ )
153
+ try {
154
+ const v = await onboardVoiceProvider({ env, timeoutMs: 12 * 60_000 })
155
+ const label = v.provider ? `${v.provider.provider} ${v.provider.version}` : 'none'
156
+ const rest = `${v.detail ? ` — ${v.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
157
+ process.stdout.write('\n' + paintResult('voice', v.state, rest, a) + '\n')
158
+ } catch (e) {
159
+ const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(set up later: `npx @agfpd/voice-connect init`)')}`
160
+ process.stdout.write('\n' + paintResult('voice', 'step skipped', rest, a) + '\n')
161
+ }
162
+ } else {
163
+ process.stdout.write(
164
+ '\n' + paintResult('voice', 'skipped', ' — set it up later with `npx @agfpd/voice-connect init`', a) + '\n',
165
+ )
166
+ }
139
167
  } finally {
140
168
  process.removeListener('SIGINT', ignore)
141
169
  for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
@@ -51,6 +51,7 @@ import { defaultDaemonSocketPath } from '../daemon/index.ts'
51
51
  // shapes but receives the component-updaters as injected deps.
52
52
  import type { UpdateRuntimeResult } from '../runtime/update.ts'
53
53
  import type { MemoryUpdateResult } from '../onboard/memory.ts'
54
+ import type { VoiceUpdateResult } from '../onboard/voice.ts'
54
55
 
55
56
  /** The npm package the foundation publishes / updates from. */
56
57
  export const IAPEER_PACKAGE = '@agfpd/iapeer'
@@ -320,10 +321,10 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
320
321
  // ─────────────────────────────────────────────────────────────────────────────
321
322
  // cascadeTail — the runtimes + memory legs of the cascade `iapeer update` (FU12).
322
323
  // Runs AFTER a healthy foundation update (the CLI owns that, abort-on-hard-fail);
323
- // here we update every installed runtime then the memory provider, BEST-EFFORT — a
324
- // component failure is reported, never aborts the rest. Pure orchestration over
325
- // injected component-updaters (no value-import of runtime/onboard → no cycle), so it
326
- // is hermetically testable. Returns whether anything failed (→ exit≠0).
324
+ // here we update every installed runtime, then the memory provider, then the voice
325
+ // provider, BEST-EFFORT — a component failure is reported, never aborts the rest. Pure
326
+ // orchestration over injected component-updaters (no value-import of runtime/onboard →
327
+ // no cycle), so it is hermetically testable. Returns whether anything failed (→ exit≠0).
327
328
  // ─────────────────────────────────────────────────────────────────────────────
328
329
 
329
330
  export interface CascadeTailDeps {
@@ -331,6 +332,8 @@ export interface CascadeTailDeps {
331
332
  runtimes: () => Promise<UpdateRuntimeResult[]>
332
333
  /** = updateMemoryProvider (onboard/memory.ts). */
333
334
  memory: () => MemoryUpdateResult | Promise<MemoryUpdateResult>
335
+ /** = updateVoiceProvider (onboard/voice.ts). */
336
+ voice: () => VoiceUpdateResult | Promise<VoiceUpdateResult>
334
337
  out: (s: string) => void
335
338
  }
336
339
 
@@ -353,5 +356,10 @@ export async function cascadeTail(deps: CascadeTailDeps): Promise<{ failed: bool
353
356
  deps.out(`memory: ${m.state}${m.package ? ` (${m.package}${mver})` : ''}${m.detail ? ` — ${m.detail}` : ''}\n`)
354
357
  if (m.state === 'failed') failed = true
355
358
 
359
+ const v = await deps.voice()
360
+ const vver = v.from || v.to ? ` ${v.from ?? '?'} → ${v.to ?? '?'}` : ''
361
+ deps.out(`voice: ${v.state}${v.package ? ` (${v.package}${vver})` : ''}${v.detail ? ` — ${v.detail}` : ''}\n`)
362
+ if (v.state === 'failed') failed = true
363
+
356
364
  return { failed }
357
365
  }