@pimote/pimote 0.3.0 → 0.4.0

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.
Files changed (50) hide show
  1. package/README.md +35 -24
  2. package/client/build/_app/immutable/assets/0.DzEJOwCY.css +2 -0
  3. package/client/build/_app/immutable/assets/2.BaqEkCa-.css +1 -0
  4. package/client/build/_app/immutable/chunks/Bu9BFOcV.js +5 -0
  5. package/client/build/_app/immutable/chunks/{D_Fpgknp.js → CC5lD39t.js} +1 -1
  6. package/client/build/_app/immutable/chunks/Cofo8G1m.js +1 -0
  7. package/client/build/_app/immutable/chunks/IBhhn0wx.js +1 -0
  8. package/client/build/_app/immutable/chunks/RduuwJ24.js +1 -0
  9. package/client/build/_app/immutable/entry/{app.DO-zgzyy.js → app.D7ddwq0U.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.8XREk-Eq.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.nayop99c.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B2l9JGRO.js → 1.BuW4kxL-.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.BParOJL4.js +54 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +3 -4
  17. package/server/dist/config.js +0 -1
  18. package/server/dist/extension-ui-bridge.js +17 -0
  19. package/server/dist/folder-index.js +1 -1
  20. package/server/dist/index.js +55 -18
  21. package/server/dist/message-mapper.js +1 -0
  22. package/server/dist/paths.js +2 -0
  23. package/server/dist/push-notification.js +11 -0
  24. package/server/dist/server.js +9 -1
  25. package/server/dist/session-manager.js +22 -8
  26. package/server/dist/static-host/gc.js +42 -0
  27. package/server/dist/static-host/http-handler.js +170 -0
  28. package/server/dist/static-host/index.js +117 -0
  29. package/server/dist/static-host/prompt.js +19 -0
  30. package/server/dist/static-host/registry.js +58 -0
  31. package/server/dist/static-host/store.js +44 -0
  32. package/server/dist/static-host/tools.js +118 -0
  33. package/server/dist/voice/fsm/reducers/lifecycle.js +14 -2
  34. package/server/dist/voice/index.js +49 -7
  35. package/server/dist/voice/speechmux-client.js +20 -3
  36. package/server/dist/voice/state-machine.js +7 -0
  37. package/server/dist/voice-orchestrator-boot.js +14 -50
  38. package/server/dist/voice-orchestrator.js +10 -22
  39. package/server/dist/ws-handler.js +4 -2
  40. package/shared/dist/protocol.d.ts +8 -0
  41. package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
  42. package/client/build/_app/immutable/assets/2.D9fiCd8W.css +0 -1
  43. package/client/build/_app/immutable/chunks/BNqgidwO.js +0 -5
  44. package/client/build/_app/immutable/chunks/D26i4pYm.js +0 -1
  45. package/client/build/_app/immutable/chunks/DoVhjU85.js +0 -1
  46. package/client/build/_app/immutable/chunks/DzqbY2XU.js +0 -1
  47. package/client/build/_app/immutable/entry/start.BZlrOH0-.js +0 -1
  48. package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +0 -10
  49. package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +0 -54
  50. package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
@@ -0,0 +1,118 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { isAbsolute, join } from 'node:path';
3
+ /**
4
+ * Validates and normalises a slug.
5
+ *
6
+ * Rules: lowercase alphanumerics and hyphens, no leading/trailing dash,
7
+ * non-empty, and a reasonable length cap (<= 64).
8
+ *
9
+ * Returns `null` if invalid.
10
+ */
11
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12
+ export function validateSlug(slug) {
13
+ if (typeof slug !== 'string')
14
+ return null;
15
+ if (slug.length === 0 || slug.length > 64)
16
+ return null;
17
+ if (!SLUG_RE.test(slug))
18
+ return null;
19
+ return slug;
20
+ }
21
+ /**
22
+ * Resolve a slug against the registry. Returns the input slug if it is free;
23
+ * otherwise appends `-2`, `-3`, ... until a free slug is found.
24
+ *
25
+ * The caller must have already validated the input slug via `validateSlug`.
26
+ */
27
+ export function resolveSlugCollision(slug, registry) {
28
+ if (!registry.has(slug))
29
+ return slug;
30
+ for (let i = 2;; i++) {
31
+ const candidate = `${slug}-${i}`;
32
+ if (!registry.has(candidate))
33
+ return candidate;
34
+ }
35
+ }
36
+ /**
37
+ * Execute the `pimote_static_host` tool body.
38
+ *
39
+ * Throws on validation failure (invalid slug, missing folder, no index.html).
40
+ * On success: updates the in-memory list for the session, atomically rewrites
41
+ * the persistence file, calls `registry.register(...)`, and emits the panel
42
+ * snapshot.
43
+ *
44
+ * Concurrency assumption: pi serializes tool calls within a single session,
45
+ * so the read-modify-write sequence on `store` here is safe. If that ever
46
+ * changes (parallel tool execution per session), this body must be revisited
47
+ * — the existing read/write pair would race and the registry/disk could
48
+ * desync. Re-derive entries from `registry.listForSession(sessionId)` after
49
+ * `registry.register`, or add a per-session lock.
50
+ */
51
+ export async function executeRegisterTool(input, deps) {
52
+ const validSlug = validateSlug(input.slug);
53
+ if (validSlug === null) {
54
+ throw new Error(`invalid slug: ${JSON.stringify(input.slug)}`);
55
+ }
56
+ if (typeof input.folder !== 'string' || !isAbsolute(input.folder)) {
57
+ throw new Error(`folder must be an absolute path: ${JSON.stringify(input.folder)}`);
58
+ }
59
+ let folderStat;
60
+ try {
61
+ folderStat = await stat(input.folder);
62
+ }
63
+ catch {
64
+ throw new Error(`folder does not exist: ${input.folder}`);
65
+ }
66
+ if (!folderStat.isDirectory()) {
67
+ throw new Error(`folder is not a directory: ${input.folder}`);
68
+ }
69
+ const indexPath = join(input.folder, 'index.html');
70
+ let indexStat;
71
+ try {
72
+ indexStat = await stat(indexPath);
73
+ }
74
+ catch {
75
+ throw new Error(`folder has no index.html: ${input.folder}`);
76
+ }
77
+ if (!indexStat.isFile()) {
78
+ throw new Error(`index.html is not a file: ${indexPath}`);
79
+ }
80
+ const resolved = resolveSlugCollision(validSlug, deps.registry);
81
+ const cardMetadata = {
82
+ title: input.title,
83
+ ...(input.tag !== undefined ? { tag: input.tag } : {}),
84
+ ...(input.color !== undefined ? { color: input.color } : {}),
85
+ };
86
+ const existing = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
87
+ const entries = [...existing.entries, { slug: resolved, folderPath: input.folder, cardMetadata }];
88
+ const file = { version: 1, entries };
89
+ await deps.store.write(deps.sessionId, file);
90
+ deps.registry.register({
91
+ slug: resolved,
92
+ folderPath: input.folder,
93
+ sessionId: deps.sessionId,
94
+ cardMetadata,
95
+ });
96
+ deps.emitPanelCards();
97
+ return { slug: resolved, url: `/s/${resolved}/` };
98
+ }
99
+ /**
100
+ * Execute the `pimote_static_host_remove` tool body. Returns `{ removed: false }`
101
+ * when the slug is not owned by this session.
102
+ *
103
+ * Concurrency assumption: same as `executeRegisterTool` — relies on pi's
104
+ * per-session serialization of tool calls. If that changes, this body must
105
+ * be revisited.
106
+ */
107
+ export async function executeRemoveTool(input, deps) {
108
+ const existing = deps.registry.lookup(input.slug);
109
+ if (!existing || existing.sessionId !== deps.sessionId) {
110
+ return { removed: false };
111
+ }
112
+ const file = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
113
+ const entries = file.entries.filter((e) => e.slug !== input.slug);
114
+ await deps.store.write(deps.sessionId, { version: 1, entries });
115
+ deps.registry.unregister(input.slug);
116
+ deps.emitPanelCards();
117
+ return { removed: true };
118
+ }
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // Holds NO knowledge of the streaming / walkback machines. Those plug in
10
10
  // through the top-level dispatcher.
11
- import { VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
11
+ import { VOICE_CALL_ENDED_SENTINEL, VOICE_CALL_STARTED_SENTINEL } from '../../state-machine.js';
12
12
  /**
13
13
  * Pure transition function for the lifecycle slice.
14
14
  *
@@ -55,11 +55,23 @@ export function reduceLifecycle(prev, event, ctx) {
55
55
  if (prev.kind === 'dormant') {
56
56
  return { next: prev, interpreterAppliedNow: false, actions: [] };
57
57
  }
58
+ // Inject an explicit end-of-call sentinel into the conversation so
59
+ // the agent has an in-history signal that voice mode is over —
60
+ // mirrors the `<voice_call_started/>` sentinel on activate. Without
61
+ // it, a session resumed in text mode after a call sees a wall of
62
+ // prior `speak()` calls and tends to mimic one more before the
63
+ // tool-side guard rejects it.
64
+ //
65
+ // Crucially this is a *silent* injection: unlike the start sentinel
66
+ // (which deliberately triggers the greeting turn), the end sentinel
67
+ // must NOT trigger a turn — the call is over, the LLM has nothing
68
+ // to do right now, and a triggered turn would just provoke one more
69
+ // speak() attempt that we then have to reject.
58
70
  // Idempotent: close_ws is a no-op if no client is open.
59
71
  return {
60
72
  next: { kind: 'dormant' },
61
73
  interpreterAppliedNow: false,
62
- actions: [{ kind: 'close_ws' }],
74
+ actions: [{ kind: 'inject_silent_user_message', customType: 'voice_call_ended', text: VOICE_CALL_ENDED_SENTINEL }, { kind: 'close_ws' }],
63
75
  };
64
76
  }
65
77
  case 'ws:opened': {
@@ -16,7 +16,7 @@
16
16
  // messages because it was reset on the wrong event (a substring of
17
17
  // `assistantMessageEvent` that never fires inside `message_update`). The
18
18
  // FSM split + correct reset-on-message_start eliminates that bug class.
19
- import { Type } from '@sinclair/typebox';
19
+ import { Type } from 'typebox';
20
20
  import { renderInterpreterPrompt } from './interpreter-prompt.js';
21
21
  import { createDefaultSpeechmuxClientFactory } from './speechmux-client.js';
22
22
  import { ensureIdleWithImplicitAbort } from './wait-for-idle.js';
@@ -141,6 +141,18 @@ export function createVoiceExtension(opts) {
141
141
  pi.sendUserMessage(action.text, action.deliverAs ? { deliverAs: action.deliverAs } : undefined);
142
142
  return;
143
143
  }
144
+ case 'inject_silent_user_message': {
145
+ // sendMessage() with a `custom` role + triggerTurn:false appends
146
+ // an entry that converts to a `role:"user"` message for the LLM
147
+ // (see core/messages.ts convertToLlm) but does not start a turn
148
+ // now. Exactly what we want for the end-of-call sentinel.
149
+ pi.sendMessage({
150
+ customType: action.customType,
151
+ content: action.text,
152
+ display: true,
153
+ }, { triggerTurn: false });
154
+ return;
155
+ }
144
156
  case 'open_ws': {
145
157
  // Reentrancy guard: close any prior client first.
146
158
  try {
@@ -156,6 +168,9 @@ export function createVoiceExtension(opts) {
156
168
  client.onFrame((frame) => {
157
169
  void dispatch({ type: 'ws:incoming', frame });
158
170
  });
171
+ client.onDisconnect(() => {
172
+ void dispatch({ type: 'ws:disconnected' });
173
+ });
159
174
  await dispatch({ type: 'ws:opened' });
160
175
  }
161
176
  catch (err) {
@@ -179,7 +194,7 @@ export function createVoiceExtension(opts) {
179
194
  console.warn('[voice] send_frame with no client — dropping', action.frame.type);
180
195
  return;
181
196
  }
182
- const preview = action.frame.type === 'token' ? action.frame.text.slice(0, 60) : null;
197
+ const preview = action.frame.type === 'token' ? action.frame.text.slice(0, 60) : action.frame.type === 'error' ? action.frame.message.slice(0, 60) : null;
183
198
  console.log('[voice_trace] send_frame', JSON.stringify({ type: action.frame.type, preview }));
184
199
  try {
185
200
  speechmuxClient.send(action.frame);
@@ -238,7 +253,12 @@ export function createVoiceExtension(opts) {
238
253
  return { content: [{ type: 'text', text: 'ok' }], details: {} };
239
254
  }
240
255
  return {
241
- content: [{ type: 'text', text: 'speak() is only available during an active voice call.' }],
256
+ content: [
257
+ {
258
+ type: 'text',
259
+ text: 'Voice call has ended. The user is now in text mode — do NOT call speak() again. Reply with normal assistant text. Any further speak() calls in this session will be rejected.',
260
+ },
261
+ ],
242
262
  details: {},
243
263
  isError: true,
244
264
  };
@@ -254,10 +274,6 @@ export function createVoiceExtension(opts) {
254
274
  // The `tool_call` hook is intentionally NOT registered. The streaming
255
275
  // reducer is the sole emitter of speak frames; bulk-emission via
256
276
  // tool_call was the source of the double-emit class of bugs.
257
- //
258
- // The `turn_end` safety net is also intentionally NOT registered.
259
- // With per-speak `end` framing driven by `toolcall_end`, it was
260
- // redundant and contributed to double-end emissions.
261
277
  pi.on('message_start', (event) => {
262
278
  // Only assistant messages reset the streaming state. User and
263
279
  // tool-result messages don't have content blocks we care about.
@@ -302,6 +318,32 @@ export function createVoiceExtension(opts) {
302
318
  return;
303
319
  }
304
320
  });
321
+ pi.on('turn_end', (event) => {
322
+ if (state.lifecycle.kind !== 'active' || !speechmuxClient)
323
+ return;
324
+ const lastSpeakResult = [...event.toolResults].reverse().find((result) => result.toolName === 'speak');
325
+ if (!lastSpeakResult)
326
+ return;
327
+ try {
328
+ speechmuxClient.send(typeof lastSpeakResult.toolCallId === 'string' ? { type: 'floor_released', speak_id: lastSpeakResult.toolCallId } : { type: 'floor_released' });
329
+ }
330
+ catch (err) {
331
+ console.warn('[voice] speechmux send failed', 'floor_released', err);
332
+ }
333
+ });
334
+ pi.on('agent_end', (event) => {
335
+ if (state.lifecycle.kind !== 'active' || !speechmuxClient)
336
+ return;
337
+ const error = event.error;
338
+ if (typeof error !== 'string' || error.length === 0)
339
+ return;
340
+ try {
341
+ speechmuxClient.send({ type: 'error', message: error });
342
+ }
343
+ catch (err) {
344
+ console.warn('[voice] speechmux send failed', 'error', err);
345
+ }
346
+ });
305
347
  pi.on('context', (event, ctx) => {
306
348
  lastCtx = ctx;
307
349
  // The walkback reducer always runs walkBack (even when no rewrite
@@ -10,8 +10,9 @@
10
10
  * Default `SpeechmuxClient` factory backed by the `ws` package. Opens a
11
11
  * WebSocket to `wsUrl` and routes incoming JSON text frames to registered
12
12
  * listeners. The LLM-WS protocol has no hello frame — the harness simply
13
- * connects and exchanges `user` / `token` / `end` / `abort` / `rollback`
14
- * frames (see speechmux/docs/llm-ws-protocol.md).
13
+ * connects and exchanges `user` / `token` / `end` / `floor_released` /
14
+ * `error` / `abort` / `rollback` frames (see
15
+ * speechmux/docs/llm-ws-protocol.md).
15
16
  *
16
17
  * Resolves once the socket is open. Rejects if the socket errors or closes
17
18
  * before opening.
@@ -28,10 +29,13 @@ export function createDefaultSpeechmuxClientFactory() {
28
29
  }
29
30
  const ws = new WsCtor(wsUrl);
30
31
  const listeners = new Set();
32
+ const disconnectListeners = new Set();
31
33
  // Buffer frames that arrive after `hello` but before the caller has had a
32
34
  // chance to attach an `onFrame` listener. Drained on the first attach.
33
35
  const pending = [];
34
36
  let closed = false;
37
+ let opened = false;
38
+ let disconnectNotified = false;
35
39
  // Install the message handler before resolving so frames sent between
36
40
  // open and the caller's onFrame attach are buffered instead of dropped.
37
41
  // See review finding 5 (speechmux-client race).
@@ -75,6 +79,7 @@ export function createDefaultSpeechmuxClientFactory() {
75
79
  if (settled)
76
80
  return;
77
81
  settled = true;
82
+ opened = true;
78
83
  cleanup();
79
84
  resolve();
80
85
  };
@@ -101,8 +106,16 @@ export function createDefaultSpeechmuxClientFactory() {
101
106
  ws.once('open', onOpen);
102
107
  ws.once('error', onError);
103
108
  });
109
+ const notifyDisconnect = () => {
110
+ if (!opened || disconnectNotified)
111
+ return;
112
+ disconnectNotified = true;
113
+ for (const listener of disconnectListeners)
114
+ listener();
115
+ };
104
116
  ws.on('close', () => {
105
117
  closed = true;
118
+ notifyDisconnect();
106
119
  });
107
120
  return {
108
121
  send(frame) {
@@ -122,6 +135,10 @@ export function createDefaultSpeechmuxClientFactory() {
122
135
  }
123
136
  return () => listeners.delete(listener);
124
137
  },
138
+ onDisconnect(listener) {
139
+ disconnectListeners.add(listener);
140
+ return () => disconnectListeners.delete(listener);
141
+ },
125
142
  close() {
126
143
  if (closed)
127
144
  return;
@@ -144,7 +161,7 @@ function isIncomingFrame(value) {
144
161
  case 'user':
145
162
  return typeof v.text === 'string';
146
163
  case 'abort':
147
- return true;
164
+ return v.reason === undefined || v.reason === 'user_speaking' || v.reason === 'barge_in' || v.reason === 'session_closed';
148
165
  case 'rollback':
149
166
  return typeof v.heard_text === 'string';
150
167
  default:
@@ -5,3 +5,10 @@
5
5
  // server-side VoiceOrchestrator.
6
6
  /** Sentinel user message appended on entry to the `active` state. */
7
7
  export const VOICE_CALL_STARTED_SENTINEL = '<voice_call_started/>';
8
+ /**
9
+ * Sentinel user message appended on exit from `active`/`activating` back to
10
+ * `dormant`. Gives the agent an explicit in-history signal that the voice
11
+ * call has ended, so subsequent turns (including future text-mode pickups
12
+ * of the same session) don't keep mimicking prior `speak()` calls.
13
+ */
14
+ export const VOICE_CALL_ENDED_SENTINEL = '<voice_call_ended/>';
@@ -1,12 +1,17 @@
1
1
  // Wire the VoiceOrchestrator together with its runtime dependencies at
2
2
  // server boot time. Kept separate from `index.ts` so the wiring is
3
- // testable (no network / child_process side-effects at import time) and
4
- // isolated from the plain HTTP/WS boot sequence.
5
- import { spawn } from 'node:child_process';
3
+ // testable and isolated from the plain HTTP/WS boot sequence.
4
+ //
5
+ // Returns `null` when voice is not configured. Speechmux is treated as an
6
+ // externally managed service (systemd, container, remote host, etc.); pimote
7
+ // no longer spawns it as a sidecar.
6
8
  import { VoiceOrchestrator } from './voice-orchestrator.js';
9
+ /** True iff the config has the URLs needed to bind a voice call. */
10
+ export function isVoiceConfigured(config) {
11
+ return Boolean(config.voice?.speechmuxSignalUrl && config.voice?.speechmuxLlmWsUrl);
12
+ }
7
13
  /**
8
14
  * Construct a VoiceOrchestrator backed by real seams:
9
- * - speechmux sidecar via `child_process.spawn`
10
15
  * - displacement = looks up current owner via clientRegistry and calls its
11
16
  * `sendDisplacedEvent(sessionId)`
12
17
  *
@@ -14,10 +19,14 @@ import { VoiceOrchestrator } from './voice-orchestrator.js';
14
19
  * per-session TURN credentials are minted by speechmux and returned to the
15
20
  * PWA in its `/signal` `session` response. Pimote's orchestrator only
16
21
  * hands out the signalling URL.
22
+ *
23
+ * Returns `null` if voice is not configured \u2014 callers should skip all voice
24
+ * wiring in that case.
17
25
  */
18
26
  export function buildVoiceOrchestrator(args) {
19
27
  const { config, sessionManager, clientRegistry } = args;
20
- let speechmuxProc = null;
28
+ if (!isVoiceConfigured(config))
29
+ return null;
21
30
  const busResolver = {
22
31
  getSlot: (sessionId) => sessionManager.getSlot(sessionId),
23
32
  getEventBus: (sessionId) => sessionManager.getSlot(sessionId)?.eventBusRef.current ?? null,
@@ -26,51 +35,6 @@ export function buildVoiceOrchestrator(args) {
26
35
  config,
27
36
  sessionManager,
28
37
  busResolver,
29
- startSpeechmux: async () => {
30
- const bin = config.voice?.speechmuxBinary;
31
- if (!bin) {
32
- console.log('[voice] speechmuxBinary not configured; assuming speechmux is externally managed (systemd, container, remote host, etc.)');
33
- return;
34
- }
35
- if (speechmuxProc)
36
- return;
37
- speechmuxProc = spawn(bin, [], { stdio: ['ignore', 'inherit', 'inherit'] });
38
- speechmuxProc.on('exit', (code, signal) => {
39
- console.warn(`[voice] speechmux exited (code=${code}, signal=${signal})`);
40
- speechmuxProc = null;
41
- });
42
- // NB: we do not wait for a ready marker here — speechmux emits readiness
43
- // to its own logs. Callers should ensure startup ordering or implement a
44
- // readiness probe as part of the Step 14 smoke.
45
- },
46
- stopSpeechmux: async () => {
47
- if (!speechmuxProc)
48
- return;
49
- const proc = speechmuxProc;
50
- speechmuxProc = null;
51
- await new Promise((resolve) => {
52
- const timer = setTimeout(() => {
53
- try {
54
- proc.kill('SIGKILL');
55
- }
56
- catch {
57
- /* ignore */
58
- }
59
- resolve();
60
- }, 2000);
61
- proc.once('exit', () => {
62
- clearTimeout(timer);
63
- resolve();
64
- });
65
- try {
66
- proc.kill('SIGTERM');
67
- }
68
- catch {
69
- clearTimeout(timer);
70
- resolve();
71
- }
72
- });
73
- },
74
38
  displaceOwner: async (sessionId, _newOwner) => {
75
39
  const slot = sessionManager.getSlot(sessionId);
76
40
  const existingClientId = slot?.connection?.connectedClientId;
@@ -1,8 +1,10 @@
1
- // Voice orchestrator — owns the speechmux sidecar lifecycle and the per-call
2
- // bind dispatch. See docs/plans/voice-mode.md → "Voice orchestrator".
1
+ // Voice orchestrator — owns per-call bind dispatch.
2
+ // See docs/plans/voice-mode.md → "Voice orchestrator".
3
3
  //
4
- // This file defines the interface surface + a stub implementation. The impl
5
- // phase fills in start()/stop()/bindCall()/endCall() bodies.
4
+ // Speechmux is treated as an externally managed service (systemd, container,
5
+ // remote host, etc.). This orchestrator is only constructed when voice config
6
+ // is present (`voice.speechmuxSignalUrl` + `voice.speechmuxLlmWsUrl`); when
7
+ // it is absent, the server skips voice wiring entirely.
6
8
  /** Typed error carrying the discriminable reason code used in PimoteResponse.error. */
7
9
  export class CallBindError extends Error {
8
10
  code;
@@ -14,24 +16,12 @@ export class CallBindError extends Error {
14
16
  }
15
17
  export class VoiceOrchestrator {
16
18
  opts;
17
- started = false;
18
19
  activeCalls = new Set();
19
20
  constructor(opts) {
20
21
  this.opts = opts;
21
22
  }
22
- /** Spawns speechmux sidecar. Throws if it fails to start. */
23
- async start() {
24
- if (this.started)
25
- return;
26
- await this.opts.startSpeechmux();
27
- this.started = true;
28
- }
29
- /** Kills speechmux. Idempotent. */
23
+ /** Drop all active-call bookkeeping. Idempotent. Called on server shutdown. */
30
24
  async stop() {
31
- if (!this.started)
32
- return;
33
- await this.opts.stopSpeechmux();
34
- this.started = false;
35
25
  this.activeCalls.clear();
36
26
  }
37
27
  /** Called by ws-handler for CallBindCommand. */
@@ -47,11 +37,9 @@ export class VoiceOrchestrator {
47
37
  if (alreadyOwned && args.force) {
48
38
  await this.opts.displaceOwner(args.sessionId, args.clientConnection);
49
39
  }
50
- // Voice-disabled guard: if speechmux wiring isn't configured, fail the
51
- // bind here rather than handing the client empty URLs. Speechmux is
52
- // what mints the per-call TURN creds now (in the /signal `session`
53
- // response) and what authenticates peers (via Cloudflare Access at the
54
- // edge), so pimote no longer needs to mint anything.
40
+ // The orchestrator is only constructed when voice is configured, so
41
+ // these URLs are guaranteed present. Re-read them per call so live
42
+ // config edits (if/when supported) take effect on the next bind.
55
43
  const signalUrl = this.opts.config.voice?.speechmuxSignalUrl;
56
44
  const llmWsUrl = this.opts.config.voice?.speechmuxLlmWsUrl;
57
45
  if (!signalUrl || !llmWsUrl) {
@@ -989,7 +989,8 @@ export class WsHandler {
989
989
  onSessionReset: (s) => this.handleSessionReset(s),
990
990
  };
991
991
  slot.connection = connection;
992
- slot.sessionState.lastActivity = Date.now();
992
+ // Note: do NOT touch `idleSince` here. Idleness is an agent-level concept driven by
993
+ // agent_start/agent_end — a client claiming a session does not extend its idle clock.
993
994
  this.subscribedSessions.add(sessionId);
994
995
  // Bind extensions when needed. The bridge holds a direct reference to this
995
996
  // ManagedSlot — on reconnect we skip rebinding, but on session reset
@@ -1321,9 +1322,10 @@ export class WsHandler {
1321
1322
  const slot = this.sessionManager.getSession(sid);
1322
1323
  if (slot) {
1323
1324
  slot.connection = null;
1324
- slot.sessionState.lastActivity = Date.now();
1325
1325
  // Note: pending UI responses are NOT resolved here — they survive
1326
1326
  // for replay on reconnect. They are resolved on session close or abort.
1327
+ // Note: do NOT touch `idleSince`. Disconnecting does not reset idleness — if the
1328
+ // agent finished 10 minutes ago, a peeking client should not extend the session's life.
1327
1329
  }
1328
1330
  }
1329
1331
  this.subscribedSessions.clear();
@@ -71,6 +71,8 @@ export interface PimoteAgentMessage {
71
71
  * these with an "interrupted" indicator rather than as empty bubbles.
72
72
  */
73
73
  aborted?: boolean;
74
+ /** Present on assistant messages whose turn failed before producing content. */
75
+ errorMessage?: string;
74
76
  [key: string]: unknown;
75
77
  }
76
78
  export type CardColor = 'accent' | 'success' | 'warning' | 'error' | 'muted';
@@ -88,6 +90,12 @@ export interface Card {
88
90
  };
89
91
  body?: BodySection[];
90
92
  footer?: string[];
93
+ /**
94
+ * Optional same-origin URL. When present, the client renders the entire
95
+ * card as a clickable link (same-tab navigation). No server-side validation
96
+ * — any string is allowed; the consumer is responsible for using a sane URL.
97
+ */
98
+ href?: string;
91
99
  }
92
100
  /** Session tree node transferred over the wire (preview-only, no full message content). */
93
101
  export interface PimoteTreeNode {