@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 +40 -1
- package/package.json +1 -1
- package/src/cli/index.ts +108 -28
- package/src/identity/index.ts +14 -5
- package/src/launch/adapters/claude.ts +10 -3
- package/src/onboard/voice.ts +204 -0
- package/src/status/index.ts +85 -9
- package/src/tui/onboard/app.tsx +32 -7
- package/src/tui/onboard/run.tsx +32 -4
- package/src/update/index.ts +12 -4
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.
|
|
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
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
|
-
|
|
842
|
+
oldCwd?: string
|
|
843
|
+
newCwd?: string
|
|
844
|
+
transcriptMoved?: boolean
|
|
842
845
|
purgedState?: string[]
|
|
846
|
+
sideEffects?: string[]
|
|
843
847
|
}
|
|
844
848
|
|
|
845
849
|
/**
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1763
|
-
// the transcript
|
|
1764
|
-
//
|
|
1765
|
-
//
|
|
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}"
|
|
1772
|
-
|
|
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
|
|
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
|
package/src/identity/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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 ${
|
|
665
|
+
`peer "${oldPersonality}" cwd ${profileCwd} profile has personality "${sourceProfile.personality}", not "${oldPersonality}"; registry out of sync`,
|
|
659
666
|
)
|
|
660
667
|
}
|
|
661
|
-
writePeerProfileAtomic(
|
|
668
|
+
writePeerProfileAtomic(profileCwd, { ...sourceProfile, personality: newPersonality })
|
|
662
669
|
return {
|
|
663
670
|
...index,
|
|
664
671
|
peers: index.peers.map(peer =>
|
|
665
|
-
peer.personality === oldPersonality
|
|
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
|
-
|
|
103
|
-
|
|
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
|
+
}
|
package/src/status/index.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Status — the host-snapshot verb: installed-binary version, daemon health, and
|
|
2
|
-
// the
|
|
3
|
-
// is DECLARATIVE: a root file `~/.iapeer/memory-provider.json`
|
|
4
|
-
// by the PROVIDER's own init/uninstall — the core only ever
|
|
5
|
-
// unreadable file is the EMPTY slot — a fully valid state
|
|
6
|
-
// error (fail-open). The core never acts on heartbeat
|
|
7
|
-
// it (healing the provider's daemon is the provider's
|
|
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
|
|
101
|
-
|
|
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
|
}
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -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 = (
|
|
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 === '
|
|
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
|
}
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -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
|
|
83
|
-
// telegram creates the human peer that memory's --human resolves from
|
|
84
|
-
// the terminal (inherited stdio / readline),
|
|
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)
|
package/src/update/index.ts
CHANGED
|
@@ -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,
|
|
324
|
-
// component failure is reported, never aborts the rest. Pure
|
|
325
|
-
// injected component-updaters (no value-import of runtime/onboard →
|
|
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
|
}
|