@pimote/pimote 0.3.1 → 0.4.1

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 (46) 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/{CklMSqcv.js → B-E0ZvqP.js} +1 -1
  5. package/client/build/_app/immutable/chunks/B8_nBK84.js +1 -0
  6. package/client/build/_app/immutable/chunks/BXTn4iTX.js +5 -0
  7. package/client/build/_app/immutable/chunks/_NcRSVb1.js +1 -0
  8. package/client/build/_app/immutable/chunks/wFL3btjl.js +1 -0
  9. package/client/build/_app/immutable/entry/{app.B-HFVtpC.js → app.B5rNDlzR.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.Ge2L0aip.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DncQszfo.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.CmxFYjRm.js → 1.Bm4mW4cF.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.CIMIcssN.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 -23
  21. package/server/dist/message-mapper.js +1 -0
  22. package/server/dist/paths.js +2 -0
  23. package/server/dist/server.js +9 -1
  24. package/server/dist/session-manager.js +21 -7
  25. package/server/dist/static-host/gc.js +42 -0
  26. package/server/dist/static-host/http-handler.js +170 -0
  27. package/server/dist/static-host/index.js +121 -0
  28. package/server/dist/static-host/prompt.js +19 -0
  29. package/server/dist/static-host/registry.js +58 -0
  30. package/server/dist/static-host/store.js +44 -0
  31. package/server/dist/static-host/tools.js +120 -0
  32. package/server/dist/voice/index.js +31 -6
  33. package/server/dist/voice/speechmux-client.js +20 -3
  34. package/server/dist/voice-orchestrator-boot.js +14 -50
  35. package/server/dist/voice-orchestrator.js +10 -22
  36. package/shared/dist/protocol.d.ts +24 -1
  37. package/client/build/_app/immutable/assets/0.C7loWTOC.css +0 -2
  38. package/client/build/_app/immutable/assets/2.DwPXxSa-.css +0 -1
  39. package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +0 -1
  40. package/client/build/_app/immutable/chunks/CO_BwWGt.js +0 -1
  41. package/client/build/_app/immutable/chunks/D1INvMB9.js +0 -1
  42. package/client/build/_app/immutable/chunks/D1vhgXpq.js +0 -5
  43. package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +0 -1
  44. package/client/build/_app/immutable/nodes/0.CepAO4xf.js +0 -10
  45. package/client/build/_app/immutable/nodes/2.DAtqfmki.js +0 -54
  46. package/patches/@mariozechner+pi-coding-agent+0.67.6.patch +0 -24
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Default in-memory implementation backed by a `Map<slug, StaticHostRegistration>`
3
+ * with a secondary `Map<sessionId, Set<slug>>` for fast `unregisterAllForSession`.
4
+ */
5
+ export class InMemoryStaticHostRegistry {
6
+ bySlug = new Map();
7
+ bySession = new Map();
8
+ register(reg) {
9
+ if (this.bySlug.has(reg.slug)) {
10
+ throw new Error(`static-host slug already registered: ${reg.slug}`);
11
+ }
12
+ this.bySlug.set(reg.slug, reg);
13
+ let set = this.bySession.get(reg.sessionId);
14
+ if (!set) {
15
+ set = new Set();
16
+ this.bySession.set(reg.sessionId, set);
17
+ }
18
+ set.add(reg.slug);
19
+ }
20
+ unregister(slug) {
21
+ const entry = this.bySlug.get(slug);
22
+ if (!entry)
23
+ return;
24
+ this.bySlug.delete(slug);
25
+ const set = this.bySession.get(entry.sessionId);
26
+ if (set) {
27
+ set.delete(slug);
28
+ if (set.size === 0)
29
+ this.bySession.delete(entry.sessionId);
30
+ }
31
+ }
32
+ unregisterAllForSession(sessionId) {
33
+ const set = this.bySession.get(sessionId);
34
+ if (!set)
35
+ return;
36
+ for (const slug of set)
37
+ this.bySlug.delete(slug);
38
+ this.bySession.delete(sessionId);
39
+ }
40
+ lookup(slug) {
41
+ return this.bySlug.get(slug);
42
+ }
43
+ has(slug) {
44
+ return this.bySlug.has(slug);
45
+ }
46
+ listForSession(sessionId) {
47
+ const set = this.bySession.get(sessionId);
48
+ if (!set)
49
+ return [];
50
+ const out = [];
51
+ for (const slug of set) {
52
+ const entry = this.bySlug.get(slug);
53
+ if (entry)
54
+ out.push(entry);
55
+ }
56
+ return out;
57
+ }
58
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Filesystem-backed `StaticHostStore`. One file per sessionId under `storeDir`.
5
+ */
6
+ export class FileStaticHostStore {
7
+ storeDir;
8
+ constructor(storeDir) {
9
+ this.storeDir = storeDir;
10
+ }
11
+ pathFor(sessionId) {
12
+ return join(this.storeDir, `${sessionId}.json`);
13
+ }
14
+ async read(sessionId) {
15
+ const path = this.pathFor(sessionId);
16
+ let raw;
17
+ try {
18
+ raw = await readFile(path, 'utf-8');
19
+ }
20
+ catch (err) {
21
+ if (err.code === 'ENOENT')
22
+ return undefined;
23
+ throw err;
24
+ }
25
+ return JSON.parse(raw);
26
+ }
27
+ async write(sessionId, file) {
28
+ await mkdir(this.storeDir, { recursive: true });
29
+ const finalPath = this.pathFor(sessionId);
30
+ const tmpPath = finalPath + '.tmp';
31
+ await writeFile(tmpPath, JSON.stringify(file, null, 2) + '\n', 'utf-8');
32
+ await rename(tmpPath, finalPath);
33
+ }
34
+ async remove(sessionId) {
35
+ try {
36
+ await unlink(this.pathFor(sessionId));
37
+ }
38
+ catch (err) {
39
+ if (err.code === 'ENOENT')
40
+ return;
41
+ throw err;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,120 @@
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
+ const url = `/s/${resolved}/`;
98
+ deps.emitNavigate(url);
99
+ return { slug: resolved, url };
100
+ }
101
+ /**
102
+ * Execute the `pimote_static_host_remove` tool body. Returns `{ removed: false }`
103
+ * when the slug is not owned by this session.
104
+ *
105
+ * Concurrency assumption: same as `executeRegisterTool` — relies on pi's
106
+ * per-session serialization of tool calls. If that changes, this body must
107
+ * be revisited.
108
+ */
109
+ export async function executeRemoveTool(input, deps) {
110
+ const existing = deps.registry.lookup(input.slug);
111
+ if (!existing || existing.sessionId !== deps.sessionId) {
112
+ return { removed: false };
113
+ }
114
+ const file = (await deps.store.read(deps.sessionId)) ?? { version: 1, entries: [] };
115
+ const entries = file.entries.filter((e) => e.slug !== input.slug);
116
+ await deps.store.write(deps.sessionId, { version: 1, entries });
117
+ deps.registry.unregister(input.slug);
118
+ deps.emitPanelCards();
119
+ return { removed: true };
120
+ }
@@ -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';
@@ -168,6 +168,9 @@ export function createVoiceExtension(opts) {
168
168
  client.onFrame((frame) => {
169
169
  void dispatch({ type: 'ws:incoming', frame });
170
170
  });
171
+ client.onDisconnect(() => {
172
+ void dispatch({ type: 'ws:disconnected' });
173
+ });
171
174
  await dispatch({ type: 'ws:opened' });
172
175
  }
173
176
  catch (err) {
@@ -191,7 +194,7 @@ export function createVoiceExtension(opts) {
191
194
  console.warn('[voice] send_frame with no client — dropping', action.frame.type);
192
195
  return;
193
196
  }
194
- 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;
195
198
  console.log('[voice_trace] send_frame', JSON.stringify({ type: action.frame.type, preview }));
196
199
  try {
197
200
  speechmuxClient.send(action.frame);
@@ -271,10 +274,6 @@ export function createVoiceExtension(opts) {
271
274
  // The `tool_call` hook is intentionally NOT registered. The streaming
272
275
  // reducer is the sole emitter of speak frames; bulk-emission via
273
276
  // tool_call was the source of the double-emit class of bugs.
274
- //
275
- // The `turn_end` safety net is also intentionally NOT registered.
276
- // With per-speak `end` framing driven by `toolcall_end`, it was
277
- // redundant and contributed to double-end emissions.
278
277
  pi.on('message_start', (event) => {
279
278
  // Only assistant messages reset the streaming state. User and
280
279
  // tool-result messages don't have content blocks we care about.
@@ -319,6 +318,32 @@ export function createVoiceExtension(opts) {
319
318
  return;
320
319
  }
321
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
+ });
322
347
  pi.on('context', (event, ctx) => {
323
348
  lastCtx = ctx;
324
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:
@@ -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) {
@@ -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 {
@@ -550,6 +558,21 @@ export interface PanelUpdateEvent {
550
558
  sessionId: string;
551
559
  cards: Card[];
552
560
  }
561
+ /**
562
+ * Server-initiated client navigation request. Emitted by extensions that
563
+ * register a new same-origin surface (e.g. `pimote_static_host`) and want
564
+ * the client to jump to it on creation. The client decides whether to act —
565
+ * the static-host extension emits this only when the session is the one
566
+ * that issued the tool call, but clients should still ignore the event when
567
+ * the targeted `sessionId` is not the currently viewed session to avoid
568
+ * yanking users browsing elsewhere.
569
+ */
570
+ export interface NavigateEvent {
571
+ type: 'pimote_navigate';
572
+ sessionId: string;
573
+ /** Same-origin URL to navigate to. */
574
+ url: string;
575
+ }
553
576
  /**
554
577
  * Success response to a CallBindCommand. Carries the per-call WebRTC
555
578
  * signalling endpoint the client should connect to. The PWA obtains
@@ -601,7 +624,7 @@ export interface VersionMismatchEvent {
601
624
  type: 'version_mismatch';
602
625
  serverVersion: string;
603
626
  }
604
- export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | VersionMismatchEvent;
627
+ export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | NavigateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | VersionMismatchEvent;
605
628
  export interface PimoteResponse<T = unknown> {
606
629
  /** Matches the `id` from the originating PimoteCommand */
607
630
  id: string;