@agfpd/voicetalk-runtime 0.4.0 → 0.4.2

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
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] - 2026-10-26
11
+
12
+ ### Changed
13
+
14
+ - `iapeer voice open` now carries `--mic-released` as the LAST argv token — the
15
+ hand-off token iapeer ≥0.4.151 requires while a voicetalk runtime is alive (no second
16
+ listener can be opened next to ours). Sent only after the capture exit is confirmed;
17
+ harmless on older cores.
18
+ - A cold peer (`status.live === false`) is woken by open itself (up to ~2 min); open
19
+ then gets a 150 s budget (`VOICETALK_NATIVE_OPEN_COLD_MS`) instead of 20 s.
20
+
21
+ ## [0.4.1] - 2026-10-26
22
+
23
+ ### Added
24
+
25
+ - `native.ready {target, state}` on start when the `iapeer voice status` preflight
26
+ answers and the target stays in native mode — a positive receipt, so a healthy start
27
+ is distinguishable from one that never ran the preflight.
28
+
10
29
  ## [0.4.0] - 2026-10-26
11
30
 
12
31
  ### Added
package/docs/README.md CHANGED
@@ -60,7 +60,8 @@ live session, which iapeer opens and closes:
60
60
  what an accepted activation does, never what counts as one.
61
61
  2. voicetalk kills its capture process and waits until it has actually exited (no
62
62
  CoreAudio client left), plays the short "heard you" earcon while nobody is
63
- listening, then runs `iapeer voice open <peer> --json`.
63
+ listening, then runs `iapeer voice open <peer> --json --mic-released` (150 s budget when the
64
+ peer has no live session — open wakes it first).
64
65
  3. While the peer holds the room voicetalk stays silent: no earcons, and any agent
65
66
  answer that arrives is spoken only after the hand-off ends.
66
67
  4. voicetalk polls `iapeer voice status <peer> --json` (1 s) and takes the microphone
package/docs/ru/README.md CHANGED
@@ -60,7 +60,8 @@ prompt: такой контекст способен сам породить и
60
60
  принятая активация, но не то, что ею считается.
61
61
  2. voicetalk гасит процесс захвата и ждёт его фактического выхода (CoreAudio-клиента
62
62
  нет), играет короткий earcon «услышал», пока никто не слушает, и вызывает
63
- `iapeer voice open <peer> --json`.
63
+ `iapeer voice open <peer> --json --mic-released` (для пира без живой сессии бюджет
64
+ 150 с — open сначала будит его).
64
65
  3. Пока комната у пира, voicetalk молчит: ни earcon'ов, а пришедший ответ агента
65
66
  озвучивается только после возврата микрофона.
66
67
  4. voicetalk опрашивает `iapeer voice status <peer> --json` (1 с) и забирает микрофон
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5,8 +5,8 @@
5
5
  // session belongs to iapeer (the pty supervisor), so voicetalk only REQUESTS and OBSERVES
6
6
  // over a small CLI contract agreed with the iapeer Implementer (topic codex-voice-wake):
7
7
  //
8
- // iapeer voice open <peer> --json → {"ok":true,"state":"open","session":"…","already":bool}
9
- // iapeer voice status <peer> --json → {"state":"open|opening|closing|closed","session":…,"reason"?:…}
8
+ // iapeer voice open <peer> --json --mic-released → {"ok":true,"state":"open","session":"…","already":bool}
9
+ // iapeer voice status <peer> --json → {"state":"open|opening|closing|closed","session":…,"reason"?:…,"live"?:bool}
10
10
  // iapeer voice close <peer> --json → {"ok":true,"state":"closed"}
11
11
  //
12
12
  // open returns only once the realtime session is confirmed started, close once it is
@@ -24,13 +24,16 @@ export type NativeVoiceReceipt = {
24
24
  session?: string | null
25
25
  already?: boolean
26
26
  reason?: string
27
+ /** status only: is the peer's session alive? A cold peer is woken by open first. */
28
+ live?: boolean
27
29
  error?: string
28
30
  /** Raw stdout/stderr tail for the log when the answer did not parse. */
29
31
  raw?: string
30
32
  }
31
33
 
32
34
  export type NativeVoiceControl = {
33
- open(peer: string): Promise<NativeVoiceReceipt>
35
+ /** Called ONLY after our capture process has provably exited. */
36
+ open(peer: string, opts?: { timeoutMs?: number }): Promise<NativeVoiceReceipt>
34
37
  status(peer: string): Promise<NativeVoiceReceipt>
35
38
  close(peer: string): Promise<NativeVoiceReceipt>
36
39
  }
@@ -52,6 +55,7 @@ export function parseVoiceReceipt(stdout: string, stderr: string, exitCode: numb
52
55
  session: typeof obj.session === 'string' ? obj.session : null,
53
56
  already: obj.already === true,
54
57
  reason: typeof obj.reason === 'string' ? obj.reason : undefined,
58
+ ...(typeof obj.live === 'boolean' ? { live: obj.live } : {}),
55
59
  error: exitCode === 0 && obj.ok !== false ? undefined : (errorText ?? `exit ${exitCode}`),
56
60
  }
57
61
  } catch {}
@@ -69,7 +73,7 @@ export function cliNativeVoice(opts: {
69
73
  statusTimeoutMs?: number
70
74
  closeTimeoutMs?: number
71
75
  }): NativeVoiceControl {
72
- const run = (verb: string, peer: string, timeoutMs: number) =>
76
+ const run = (verb: string, peer: string, timeoutMs: number, extra: string[] = []) =>
73
77
  new Promise<NativeVoiceReceipt>(resolve => {
74
78
  let stdout = ''
75
79
  let stderr = ''
@@ -82,7 +86,7 @@ export function cliNativeVoice(opts: {
82
86
  }
83
87
  let child: ReturnType<typeof spawn>
84
88
  try {
85
- child = spawn(opts.bin, ['voice', verb, peer, '--json'], {
89
+ child = spawn(opts.bin, ['voice', verb, peer, '--json', ...extra], {
86
90
  cwd: opts.cwd,
87
91
  env: opts.env ?? process.env,
88
92
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -103,7 +107,11 @@ export function cliNativeVoice(opts: {
103
107
  child.on('close', code => finish(parseVoiceReceipt(stdout, stderr, code)))
104
108
  })
105
109
  return {
106
- open: peer => run('open', peer, opts.openTimeoutMs ?? 20_000),
110
+ // `--mic-released` is the hand-off token (iapeer ≥0.4.151): while a voicetalk runtime
111
+ // is alive, iapeer accepts open ONLY with it, so nobody can open a second listener
112
+ // next to ours. It goes LAST in argv — an older parser that does not know the flag
113
+ // would otherwise swallow the next token as its value.
114
+ open: (peer, o) => run('open', peer, o?.timeoutMs ?? opts.openTimeoutMs ?? 20_000, ['--mic-released']),
107
115
  status: peer => run('status', peer, opts.statusTimeoutMs ?? 5_000),
108
116
  close: peer => run('close', peer, opts.closeTimeoutMs ?? 15_000),
109
117
  }
@@ -110,6 +110,9 @@ const WAKE_CAPTURE_MAX = Math.min(100, num(process.env.VOICETALK_WAKE_CAPTURE_MA
110
110
  * to iapeer; the ceiling only bounds a hand-off whose closing was never confirmed. */
111
111
  const NATIVE_POLL_MS = num(process.env.VOICETALK_NATIVE_POLL_MS, 1_000)
112
112
  const NATIVE_MAX_MS = num(process.env.VOICETALK_NATIVE_MAX_MS, 30 * 60_000)
113
+ /** open on a peer with no live session first wakes it (one short turn, up to ~2 min per
114
+ * iapeer); killing that open at the warm 20 s budget would only guarantee a failure. */
115
+ const NATIVE_OPEN_COLD_MS = num(process.env.VOICETALK_NATIVE_OPEN_COLD_MS, 150_000)
113
116
 
114
117
  export type DeliverFn = (target: string, text: string) => Promise<{ ok: boolean; detail?: string }>
115
118
  export type LogFn = (evt: string, fields: Record<string, unknown>) => void
@@ -653,9 +656,12 @@ export class VoiceSession {
653
656
  await this.earcon('end')
654
657
  if (this.stopped) return
655
658
  this.native!.phase = 'opening'
656
- this.opts.log('native.open-requested', { target })
659
+ // A cold peer (no live session) is woken by open itself: give it the long budget.
660
+ const pre = await control.status(target)
661
+ const cold = pre.live === false
662
+ this.opts.log('native.open-requested', { target, cold })
657
663
  const started = Date.now()
658
- const receipt = await control.open(target)
664
+ const receipt = await control.open(target, cold ? { timeoutMs: NATIVE_OPEN_COLD_MS } : undefined)
659
665
  if (!receipt.ok || receipt.state !== 'open') {
660
666
  // A failed open is NOT evidence that the peer is off the microphone: the realtime
661
667
  // session may still start late, and a timeout or an unreadable answer says nothing
@@ -750,6 +756,10 @@ export class VoiceSession {
750
756
  void this.holdUntilClosed(target)
751
757
  return true
752
758
  }
759
+ // Positive preflight receipt: the contract answered and the target is live in
760
+ // native mode. Without this line a healthy start is indistinguishable from one that
761
+ // never ran the preflight.
762
+ this.opts.log('native.ready', { target, state: st.state })
753
763
  }
754
764
  return false
755
765
  }