@agfpd/iapeer 0.4.17 → 0.4.19

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,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.19] - 2026-06-22
14
+
15
+ ### Added
16
+
17
+ - **Voice provider slot** (`@agfpd/voice-connect`) — a second declarative provider slot
18
+ alongside memory, integrated into the host lifecycle:
19
+ - `iapeer onboard` now offers an optional **DEFAULT-YES** voice step (after memory) in
20
+ both the linear path (`--no-voice` / `--voice <pkg>`) and the interactive wizard. It
21
+ installs the voice provider's **HOST BACKEND only** (a self-managed launchd HTTP
22
+ TTS/STT service) via the provider's own `init` (inherited stdio) — per-peer voice
23
+ tooling stays the separate explicit `iapeer enable voice-connect <peer>`. Report-only
24
+ for the exit code (an empty slot is a valid state).
25
+ - `iapeer update` cascades to the voice provider (best-effort, version-gated) via its
26
+ own `npm exec --package=@agfpd/voice-connect@latest -- voice-connect update` verb —
27
+ after foundation + runtimes + memory.
28
+ - `iapeer status` shows a `voice:` line (`<provider> <version> @ <endpoint>` + heartbeat
29
+ freshness, or `none`). The voice slot is read-only to the core, fail-open; unlike
30
+ memory it carries no provision/unprovision (voice is opt-in per-peer, not at birth)
31
+ and adds an `endpoint` for HTTP-facade discovery (telegram-STT / voicetalk).
32
+
33
+ ## [0.4.18] - 2026-06-22
34
+
35
+ ### Changed
36
+
37
+ - `iapeer rename <old> <new>` now performs a **full folder rename** (extends 0.4.17's
38
+ personality-only rename). The personality is a self-healed mirror of `normalize(basename(cwd))`,
39
+ so a rename that kept the cwd would be reverted by profile-standard self-heal — the folder
40
+ must move. The verb now atomically moves the cwd folder (the per-cwd profile, `.mcp.json`,
41
+ native-memory, `CLAUDE.md`, and the `.git` clone ride it), moves the claude transcript slug
42
+ dir (`~/.claude/projects/<slug>`, keyed by `realpath(cwd)` — so claude history is preserved),
43
+ and updates the registry cwd + personality + per-cwd profile, with rollback of the fs moves on
44
+ failure. Best-effort afterwards: re-trust the new cwd for codex + clean the old, rewrite the
45
+ claude `.mcp.json` identity, purge the old identity's lifecycle markers. Refuses a LIVE peer
46
+ unless `--force`. Memory (operativka folder + author/index attribution) re-key is the memory
47
+ provider's separate step; codex resume-history (keyed by the cwd recorded inside each session
48
+ file, not a path-dir) does not carry to the new cwd.
49
+
13
50
  ## [0.4.17] - 2026-06-21
14
51
 
15
52
  ### Added
@@ -257,7 +294,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
257
294
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
258
295
  (timer = cron, watcher = event) are all first-class and addressed the same way.
259
296
 
260
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.17...HEAD
297
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.19...HEAD
298
+ [0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
299
+ [0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
261
300
  [0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
262
301
  [0.4.16]: https://github.com/agfpd/iapeer/compare/v0.4.15...v0.4.16
263
302
  [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
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
@@ -11,7 +11,7 @@
11
11
  // sentinel-marked always-on plist) are stop/start-able.
12
12
 
13
13
  import { spawnSync } from 'child_process'
14
- import { existsSync, readFileSync } from 'fs'
14
+ import { existsSync, readFileSync, renameSync } from 'fs'
15
15
  import { fileURLToPath } from 'url'
16
16
  import { dirname, join } from 'path'
17
17
  import {
@@ -54,6 +54,7 @@ import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the su
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
56
  import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
57
+ import { claudeProjectsRoot, transcriptSlug } from '../launch/adapters/claude.ts'
57
58
  import { peerProfilePath } from '../storage/index.ts'
58
59
  import {
59
60
  isConformant,
@@ -836,27 +837,37 @@ export async function removePeerCli(
836
837
  export interface RenameOutcome {
837
838
  oldPersonality: string
838
839
  newPersonality: string
839
- action: 'renamed' | 'absent' | 'target-exists' | 'refused-live'
840
+ action: 'renamed' | 'absent' | 'target-exists' | 'target-cwd-exists' | 'refused-live'
840
841
  reason?: string
841
- cwd?: string
842
+ oldCwd?: string
843
+ newCwd?: string
844
+ transcriptMoved?: boolean
842
845
  purgedState?: string[]
846
+ sideEffects?: string[]
843
847
  }
844
848
 
845
849
  /**
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.
850
+ * FULL folder rename of a peer (Arthur's invariant: personality == normalize(basename(cwd)),
851
+ * a self-healed mirror — so a rename MUST move the folder, or profile-standard self-heal
852
+ * reverts the personality back to the basename). Atomic core, best-effort side-effects:
853
+ * 1. mv the cwd folder oldCwd → dirname(oldCwd)/<new> (the per-cwd profile, .mcp.json,
854
+ * native-memory, CLAUDE.md, .git all ride it).
855
+ * 2. mv the claude transcript slug dir (~/.claude/projects/<oldSlug> → <newSlug>) — keyed
856
+ * by realpath(cwd), so this is how the claude history is NOT orphaned. (codex history
857
+ * is keyed by the cwd recorded INSIDE each ~/.codex/sessions jsonl, not a path-dir, so
858
+ * it does NOT carry — a documented limitation; codex starts fresh sessions at the new cwd.)
859
+ * 3. renamePeer(…, newCwd): atomic registry personality+cwd + per-cwd profile personality.
860
+ * On any failure in 1-3 the fs moves are rolled back (folder + transcript back). Then
861
+ * best-effort: codex re-trust the new cwd + clean the old, re-write the claude .mcp.json
862
+ * identity fallback, purge the old identity's lifecycle markers. Refuses a LIVE peer unless
863
+ * --force. Does NOT touch memory (operativka/author/index) — the provider re-keys that
864
+ * separately AFTER this rename; deliberately NO `remove`/unprovision (which would risk a
865
+ * memory purge). `claudeProjectsDir` is a test seam (default ~/.claude/projects).
855
866
  */
856
867
  export async function renamePeerCli(
857
868
  oldPersonality: string,
858
869
  newPersonality: string,
859
- opts: CliEnvOptions & { force?: boolean } = {},
870
+ opts: CliEnvOptions & { force?: boolean; claudeProjectsDir?: string } = {},
860
871
  ): Promise<RenameOutcome> {
861
872
  const env = opts.env ?? process.env
862
873
  const index = readPeersIndex({ env })
@@ -865,6 +876,11 @@ export async function renamePeerCli(
865
876
  if (findPeer(index, newPersonality)) {
866
877
  return { oldPersonality, newPersonality, action: 'target-exists', reason: `"${newPersonality}" already exists` }
867
878
  }
879
+ const oldCwd = peer.cwd
880
+ const newCwd = join(dirname(oldCwd), newPersonality)
881
+ if (existsSync(newCwd)) {
882
+ return { oldPersonality, newPersonality, action: 'target-cwd-exists', reason: `${newCwd} already exists — move or remove it first` }
883
+ }
868
884
  const cfg = loadLifecycleConfig(env)
869
885
  if (!opts.force) {
870
886
  const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
@@ -873,13 +889,57 @@ export async function renamePeerCli(
873
889
  oldPersonality,
874
890
  newPersonality,
875
891
  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`,
892
+ reason: `"${oldPersonality}" is LIVE on ${liveRt} — its session + folder must be quiescent for the move; stop it first (\`iapeer stop ${oldPersonality}\`) or pass --force`,
877
893
  }
878
894
  }
879
895
  }
880
- await renamePeer(oldPersonality, newPersonality, { env })
896
+ // ── atomic core (1-3) with rollback ──────────────────────────────────────────
897
+ const projectsDir = opts.claudeProjectsDir ?? claudeProjectsRoot()
898
+ const oldSlug = transcriptSlug(oldCwd) // BEFORE the mv — realpath needs the live dir
899
+ const oldTx = join(projectsDir, oldSlug)
900
+ renameSync(oldCwd, newCwd) // 1. folder (atomic on one filesystem)
901
+ let transcriptMoved = false
902
+ let newTx: string | undefined
903
+ try {
904
+ const newSlug = transcriptSlug(newCwd) // AFTER the mv — realpath(newCwd) resolves
905
+ newTx = join(projectsDir, newSlug)
906
+ if (oldSlug !== newSlug && existsSync(oldTx) && !existsSync(newTx)) {
907
+ renameSync(oldTx, newTx) // 2. claude transcript slug dir
908
+ transcriptMoved = true
909
+ }
910
+ await renamePeer(oldPersonality, newPersonality, { env }, newCwd) // 3. atomic registry+profile
911
+ } catch (e) {
912
+ if (transcriptMoved && newTx) {
913
+ try { renameSync(newTx, oldTx) } catch { /* best-effort rollback */ }
914
+ }
915
+ try { renameSync(newCwd, oldCwd) } catch { /* best-effort rollback */ }
916
+ throw e
917
+ }
918
+ // ── best-effort side-effects (the core rename already committed) ──────────────
919
+ const sideEffects: string[] = []
920
+ if (peer.runtimes.includes('codex')) {
921
+ try {
922
+ const { preTrustCodexCwd, removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
923
+ preTrustCodexCwd(newCwd, env)
924
+ removeCodexCwdTrust(oldCwd, env)
925
+ const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
926
+ removeCodexHooksTrustUnder(oldCwd, env)
927
+ sideEffects.push('codex-trust: re-trusted new cwd + cleaned old')
928
+ } catch (e) {
929
+ sideEffects.push(`codex-trust: failed (${e instanceof Error ? e.message : String(e)})`)
930
+ }
931
+ }
932
+ if (peer.runtimes.includes('claude')) {
933
+ try {
934
+ const { writeClaudeMcpConfig, resolveDaemonMcpUrl } = await import('../init/index.ts')
935
+ writeClaudeMcpConfig(newCwd, newPersonality, resolveDaemonMcpUrl({ env }))
936
+ sideEffects.push('claude .mcp.json: identity rewritten')
937
+ } catch (e) {
938
+ sideEffects.push(`claude .mcp.json: failed (${e instanceof Error ? e.message : String(e)})`)
939
+ }
940
+ }
881
941
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, oldPersonality)))
882
- return { oldPersonality, newPersonality, action: 'renamed', cwd: peer.cwd, purgedState }
942
+ return { oldPersonality, newPersonality, action: 'renamed', oldCwd, newCwd, transcriptMoved, purgedState, sideEffects }
883
943
  }
884
944
 
885
945
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1028,10 +1088,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1028
1088
  { sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
1029
1089
  { sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
1030
1090
  {
1031
- sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]',
1032
- 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',
1033
1093
  },
1034
- { 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)' },
1035
1095
  { sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
1036
1096
  { sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
1037
1097
  { sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
@@ -1054,7 +1114,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1054
1114
  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)',
1055
1115
  },
1056
1116
  { 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.' },
1117
+ { sig: 'rename <old> <new> [--force]', desc: 'rename a peer — moves the cwd folder + claude transcript + atomic registry/profile (personality = folder name); refuses a LIVE peer unless --force. Memory re-key is the provider\'s separate step.' },
1058
1118
  {
1059
1119
  sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
1060
1120
  desc: 'manual IAP send (fallback)',
@@ -1171,7 +1231,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1171
1231
  // Interactive TUI wizard (Фаза TUI-редизайн) — the DEFAULT for an
1172
1232
  // interactive `iapeer onboard`. Routed ONLY in a real interactive terminal
1173
1233
  // and never for an automation path (--accept-risk / --dry-run / --no-memory
1174
- // / --infra), which stay on the deterministic linear flow below. Escape
1234
+ // / --no-voice / --infra), which stay on the deterministic linear flow below. Escape
1175
1235
  // hatch: IAPEER_ONBOARD_WIZARD=0 forces the linear path (recovery / scripts
1176
1236
  // that want the old flow). The wizard owns its own gate screen, so we route
1177
1237
  // BEFORE the readline gate. It fails closed: no real TTY →
@@ -1181,6 +1241,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1181
1241
  flags['accept-risk'] === true ||
1182
1242
  flags['dry-run'] === true ||
1183
1243
  flags['no-memory'] === true ||
1244
+ flags['no-voice'] === true ||
1184
1245
  typeof flags.infra === 'string'
1185
1246
  if (!wizardOptOut && !automationFlag && process.stdin.isTTY === true && process.stdout.isTTY === true) {
1186
1247
  const { runOnboardWizard, WIZARD_NOT_INTERACTIVE } = await import('../tui/onboard/run.tsx')
@@ -1282,6 +1343,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1282
1343
  })
1283
1344
  const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
1284
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`)
1285
1359
  // Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
1286
1360
  // interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
1287
1361
  // CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
@@ -1759,19 +1833,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1759
1833
  return o.action === 'refused-live' ? 1 : 0
1760
1834
  }
1761
1835
  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.
1836
+ // FULL folder rename (Arthur's invariant: personality == basename(cwd)): mv the
1837
+ // folder + the claude transcript slug dir + atomic registry cwd+personality, with
1838
+ // rollback; best-effort codex re-trust + .mcp.json rewrite + old-marker purge.
1839
+ // Refuses a LIVE peer unless --force. Memory + codex-history caveats are stated —
1840
+ // a silent data move reads as loss otherwise.
1766
1841
  if (!positionals[0] || !positionals[1]) {
1767
1842
  return argErr(errOut, 'rename needs old and new names — usage: iapeer rename <old> <new> [--force]')
1768
1843
  }
1769
1844
  const o = await renamePeerCli(positionals[0], positionals[1], { force: flags.force === true, env })
1770
1845
  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`)
1846
+ out(`renamed "${o.oldPersonality}" → "${o.newPersonality}"\n`)
1847
+ out(` folder: ${o.oldCwd} → ${o.newCwd}\n`)
1848
+ out(` claude transcript: ${o.transcriptMoved ? 'moved (history preserved)' : 'no slug dir to move'}\n`)
1849
+ if (o.sideEffects?.length) out(` ${o.sideEffects.join('\n ')}\n`)
1850
+ if (o.purgedState?.length) out(` lifecycle state purged (old identity): ${o.purgedState.join(', ')}\n`)
1773
1851
  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`,
1852
+ `NOTE: memory (operativka folder + author/index) is personality-keyed — re-key it under "${o.newPersonality}" via the memory provider separately. codex resume-history for the old cwd does NOT carry (codex keys sessions on the in-file cwd). Wake "${o.newPersonality}" to verify identity + history.\n`,
1775
1853
  )
1776
1854
  } else if (o.action === 'absent') errOut(`rename: "${o.oldPersonality}" not registered\n`)
1777
1855
  else errOut(`rename: ${o.reason}\n`)
@@ -1881,9 +1959,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1881
1959
  // ── Cascade tail: runtimes + memory provider (best-effort) ──
1882
1960
  const { updateAllRuntimes } = await import('../runtime/update.ts')
1883
1961
  const { updateMemoryProvider } = await import('../onboard/memory.ts')
1962
+ const { updateVoiceProvider } = await import('../onboard/voice.ts')
1884
1963
  const tail = await cascadeTail({
1885
1964
  runtimes: () => updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) }),
1886
1965
  memory: () => updateMemoryProvider({ env }),
1966
+ voice: () => updateVoiceProvider({ env }),
1887
1967
  out,
1888
1968
  })
1889
1969
  return foundationExit === 0 && !tail.failed ? 0 : 1
@@ -634,6 +634,7 @@ export async function renamePeer(
634
634
  oldPersonality: string,
635
635
  newPersonality: string,
636
636
  options: PeersUpdateOptions = {},
637
+ newCwd?: string,
637
638
  ): Promise<PeersIndex> {
638
639
  if (!isValidName(oldPersonality) || !isValidName(newPersonality)) {
639
640
  throw new IapError('peers rename requires valid personalities')
@@ -647,22 +648,30 @@ export async function renamePeer(
647
648
  if (index.peers.some(peer => peer.personality === newPersonality)) {
648
649
  throw new IapError(`peer "${newPersonality}" already exists`)
649
650
  }
650
- const sourceProfile = readPeerProfile(target.cwd)
651
+ // newCwd: the FOLDER was already moved by the caller (the cwd-move rename), so the
652
+ // profile now lives at newCwd; read/rewrite it THERE and update the registry cwd
653
+ // pointer atomically with the personality. Omitted → keep-cwd rename (profile at the
654
+ // registry's current cwd). The folder mv + transcript mv are the caller's side (cli),
655
+ // done BEFORE this atomic registry write (with rollback on failure).
656
+ const profileCwd = newCwd ?? target.cwd
657
+ const sourceProfile = readPeerProfile(profileCwd)
651
658
  if (!sourceProfile) {
652
659
  throw new IapError(
653
- `peer "${oldPersonality}" cwd ${target.cwd} has no ${IAPEER_DIR}/${PEER_PROFILE_FILE}; restore the cwd or remove the registry entry`,
660
+ `peer "${oldPersonality}" cwd ${profileCwd} has no ${IAPEER_DIR}/${PEER_PROFILE_FILE}; restore the cwd or remove the registry entry`,
654
661
  )
655
662
  }
656
663
  if (sourceProfile.personality !== oldPersonality) {
657
664
  throw new IapError(
658
- `peer "${oldPersonality}" cwd ${target.cwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
665
+ `peer "${oldPersonality}" cwd ${profileCwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
659
666
  )
660
667
  }
661
- writePeerProfileAtomic(target.cwd, { ...sourceProfile, personality: newPersonality })
668
+ writePeerProfileAtomic(profileCwd, { ...sourceProfile, personality: newPersonality })
662
669
  return {
663
670
  ...index,
664
671
  peers: index.peers.map(peer =>
665
- peer.personality === oldPersonality ? { ...peer, personality: newPersonality } : peer,
672
+ peer.personality === oldPersonality
673
+ ? { ...peer, personality: newPersonality, ...(newCwd ? { cwd: newCwd } : {}) }
674
+ : peer,
666
675
  ),
667
676
  }
668
677
  }, options)
@@ -89,7 +89,7 @@ function anyBootDialog(pane: string): boolean {
89
89
  * mkdtemp temp cwd. realpath first so a symlinked cwd maps to the dir claude
90
90
  * actually wrote (a stale path falls through to the original string).
91
91
  */
92
- function transcriptSlug(workDir: string): string {
92
+ export function transcriptSlug(workDir: string): string {
93
93
  let phys = workDir
94
94
  try {
95
95
  phys = realpathSync(workDir)
@@ -99,8 +99,15 @@ function transcriptSlug(workDir: string): string {
99
99
  return phys.replace(/[^a-zA-Z0-9]/g, '-')
100
100
  }
101
101
 
102
- function transcriptDir(workDir: string): string {
103
- return join(homedir(), '.claude', 'projects', transcriptSlug(workDir))
102
+ /** The base dir holding all claude project transcript slugs (~/.claude/projects).
103
+ * Exported so a cwd-move (peer folder rename) can relocate the slug dir from the
104
+ * SINGLE source of the slug encoding, never a re-implementation that could drift. */
105
+ export function claudeProjectsRoot(): string {
106
+ return join(homedir(), '.claude', 'projects')
107
+ }
108
+
109
+ export function transcriptDir(workDir: string): string {
110
+ return join(claudeProjectsRoot(), transcriptSlug(workDir))
104
111
  }
105
112
 
106
113
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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
  }
@@ -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
  }