@pimote/pimote 0.2.0 → 0.3.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 (54) hide show
  1. package/README.md +43 -16
  2. package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
  3. package/client/build/_app/immutable/assets/2.DwPXxSa-.css +1 -0
  4. package/client/build/_app/immutable/chunks/-Lc-U-GJ.js +1 -0
  5. package/client/build/_app/immutable/chunks/{CT6ckxpD.js → CO_BwWGt.js} +1 -1
  6. package/client/build/_app/immutable/chunks/CklMSqcv.js +1 -0
  7. package/client/build/_app/immutable/chunks/D1INvMB9.js +1 -0
  8. package/client/build/_app/immutable/chunks/D1vhgXpq.js +5 -0
  9. package/client/build/_app/immutable/entry/{app.CNzpBgAg.js → app.B-HFVtpC.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.DJTQ8-sD.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.CepAO4xf.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B8zmHMre.js → 1.CmxFYjRm.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.DAtqfmki.js +54 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +7 -3
  17. package/server/dist/auto-drain-on-abort.js +49 -0
  18. package/server/dist/config.js +21 -0
  19. package/server/dist/extension-ui-bridge.js +14 -1
  20. package/server/dist/index.js +36 -1
  21. package/server/dist/message-mapper.js +38 -6
  22. package/server/dist/push-notification.js +11 -0
  23. package/server/dist/server.js +2 -2
  24. package/server/dist/session-manager.js +72 -4
  25. package/server/dist/voice/fsm/actions.js +6 -0
  26. package/server/dist/voice/fsm/events.js +7 -0
  27. package/server/dist/voice/fsm/reducer.js +74 -0
  28. package/server/dist/voice/fsm/reducers/lifecycle.js +158 -0
  29. package/server/dist/voice/fsm/reducers/streaming.js +220 -0
  30. package/server/dist/voice/fsm/reducers/walkback.js +73 -0
  31. package/server/dist/voice/fsm/state.js +21 -0
  32. package/server/dist/voice/fsm/text-extractor.js +128 -0
  33. package/server/dist/voice/index.js +336 -0
  34. package/server/dist/voice/interpreter-prompt.js +115 -0
  35. package/server/dist/voice/speechmux-client.js +153 -0
  36. package/server/dist/voice/state-machine.js +14 -0
  37. package/server/dist/voice/wait-for-idle.js +67 -0
  38. package/server/dist/voice/walk-back.js +198 -0
  39. package/server/dist/voice-orchestrator-boot.js +90 -0
  40. package/server/dist/voice-orchestrator.js +91 -0
  41. package/server/dist/ws-handler.js +112 -7
  42. package/shared/dist/index.d.ts +1 -0
  43. package/shared/dist/index.js +2 -0
  44. package/shared/dist/protocol.d.ts +614 -0
  45. package/shared/dist/protocol.js +30 -0
  46. package/client/build/_app/immutable/assets/0.DBrr7n4n.css +0 -2
  47. package/client/build/_app/immutable/assets/2.DE6k3bQj.css +0 -1
  48. package/client/build/_app/immutable/chunks/5vSSf6qG.js +0 -5
  49. package/client/build/_app/immutable/chunks/DlJOVoUQ.js +0 -1
  50. package/client/build/_app/immutable/chunks/YxmLwfhj.js +0 -1
  51. package/client/build/_app/immutable/chunks/yWVx3W2o.js +0 -1
  52. package/client/build/_app/immutable/entry/start.DYkTAHh1.js +0 -1
  53. package/client/build/_app/immutable/nodes/0.DNlQhEb_.js +0 -10
  54. package/client/build/_app/immutable/nodes/2.W9yV4-x2.js +0 -54
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimote/pimote",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Web client and embedded server for pi with multi-session browser access, streaming, and extension UI support",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,8 @@
29
29
  "bin/pimote.js",
30
30
  "client/build/**",
31
31
  "server/dist/**/*.js",
32
+ "shared/dist/**/*.js",
33
+ "shared/dist/**/*.d.ts",
32
34
  "scripts/postinstall-patches.mjs",
33
35
  "patches/@mariozechner+pi-coding-agent+0.67.6.patch",
34
36
  "README.md",
@@ -38,13 +40,13 @@
38
40
  "node": ">=22.0.0"
39
41
  },
40
42
  "workspaces": [
41
- "shared",
42
43
  "server",
43
44
  "client",
44
45
  "packages/panels"
45
46
  ],
46
47
  "scripts": {
47
- "build": "npm run build --workspace=shared && npm run build --workspace=@pimote/server && npm run build --workspace=client",
48
+ "build": "npm run build:shared && npm run build --workspace=@pimote/server && npm run build --workspace=client",
49
+ "build:shared": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('shared/dist', { recursive: true, force: true }); rmSync('shared/tsconfig.tsbuildinfo', { force: true });\" && tsc -b shared --force",
48
50
  "start": "node ./bin/pimote.js start",
49
51
  "format": "prettier --write .",
50
52
  "format:check": "prettier --check .",
@@ -74,6 +76,8 @@
74
76
  "dependencies": {
75
77
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
76
78
  "@mariozechner/pi-coding-agent": "0.67.6",
79
+ "@sinclair/typebox": "^0.34.49",
80
+ "@streamparser/json": "^0.0.22",
77
81
  "patch-package": "^8.0.1",
78
82
  "web-push": "^3.6.7",
79
83
  "ws": "^8.20.0"
@@ -0,0 +1,49 @@
1
+ // Auto-drain queued steering / follow-up messages after an aborted run.
2
+ //
3
+ // pi-agent-core's `runLoop` exits without polling the steering queue when
4
+ // an abort signal fires mid-stream (see `agent-loop.js` — the
5
+ // `stopReason === 'aborted'` branch returns immediately, before the
6
+ // trailing `getSteeringMessages` poll). Any messages queued before / during
7
+ // the abort would otherwise sit in the queue until something else calls
8
+ // `agent.prompt()` or `agent.continue()`.
9
+ //
10
+ // In pimote this surfaces as silently-dropped user messages whenever a
11
+ // queued steer races with an abort — most visibly during voice-mode
12
+ // barge-in, but also for typed-mode users who queue a follow-up while the
13
+ // agent is streaming and then hit the abort button.
14
+ //
15
+ // The fix is to call `agent.continue()` after the run settles: from the
16
+ // pi-agent-core source, `continue()` explicitly drains the steering queue
17
+ // (and falls back to follow-up if steering is empty) and replays the
18
+ // drained messages as a fresh prompt.
19
+ /**
20
+ * If `lastMessage` is the aborted-assistant synthetic that pi appends on
21
+ * abort and the session has queued messages, wait for the current run to
22
+ * settle and then drain the queue via `agent.continue()`.
23
+ *
24
+ * Idempotent: after `continue()` runs, the queue is empty so a re-entry
25
+ * (e.g. another agent_end) becomes a no-op. Errors from `continue()`
26
+ * (notably "Agent is already processing" when something else races us to
27
+ * a new prompt) are swallowed via `onError` — losing the auto-drain in
28
+ * that case is acceptable because the racing prompt itself will drain
29
+ * the queue inside `runLoop`'s initial `getSteeringMessages` poll.
30
+ */
31
+ export async function autoDrainOnAbort(session, lastMessage, onError = (err) => console.warn('[pimote] auto-drain after abort failed', err)) {
32
+ if (!lastMessage || lastMessage.stopReason !== 'aborted') {
33
+ return;
34
+ }
35
+ try {
36
+ // The `agent_end` listener fires before `finishRun()` clears the
37
+ // run's `activeRun` reference. `waitForIdle()` resolves on the same
38
+ // promise that `finishRun` resolves, so awaiting it parks us until
39
+ // `agent.continue()` is safe to call without throwing
40
+ // "Agent is already processing".
41
+ await session.agent.waitForIdle();
42
+ if (session.pendingMessageCount === 0)
43
+ return;
44
+ await session.agent.continue();
45
+ }
46
+ catch (err) {
47
+ onError(err);
48
+ }
49
+ }
@@ -48,11 +48,32 @@ export async function loadConfig() {
48
48
  defaultProvider: typeof obj.defaultProvider === 'string' ? obj.defaultProvider : undefined,
49
49
  defaultModel: typeof obj.defaultModel === 'string' ? obj.defaultModel : undefined,
50
50
  defaultThinkingLevel: typeof obj.defaultThinkingLevel === 'string' ? obj.defaultThinkingLevel : undefined,
51
+ defaultInterpreterModel: parseModelRef(obj.defaultInterpreterModel),
52
+ defaultWorkerModel: parseModelRef(obj.defaultWorkerModel),
53
+ voice: parseVoiceConfig(obj.voice),
51
54
  vapidPublicKey: typeof obj.vapidPublicKey === 'string' ? obj.vapidPublicKey : undefined,
52
55
  vapidPrivateKey: typeof obj.vapidPrivateKey === 'string' ? obj.vapidPrivateKey : undefined,
53
56
  vapidEmail: typeof obj.vapidEmail === 'string' ? obj.vapidEmail : undefined,
54
57
  };
55
58
  }
59
+ function parseModelRef(v) {
60
+ if (!v || typeof v !== 'object')
61
+ return undefined;
62
+ const o = v;
63
+ if (typeof o.provider !== 'string' || typeof o.modelId !== 'string')
64
+ return undefined;
65
+ return { provider: o.provider, modelId: o.modelId };
66
+ }
67
+ function parseVoiceConfig(v) {
68
+ if (!v || typeof v !== 'object')
69
+ return undefined;
70
+ const o = v;
71
+ return {
72
+ speechmuxBinary: typeof o.speechmuxBinary === 'string' ? o.speechmuxBinary : undefined,
73
+ speechmuxSignalUrl: typeof o.speechmuxSignalUrl === 'string' ? o.speechmuxSignalUrl : undefined,
74
+ speechmuxLlmWsUrl: typeof o.speechmuxLlmWsUrl === 'string' ? o.speechmuxLlmWsUrl : undefined,
75
+ };
76
+ }
56
77
  export async function ensureVapidKeys(config) {
57
78
  if (config.vapidPublicKey && config.vapidPrivateKey) {
58
79
  return config;
@@ -1,3 +1,4 @@
1
+ import { UI_BRIDGE_DISABLED_IN_VOICE_MODE } from '../../shared/dist/index.js';
1
2
  import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
2
3
  /**
3
4
  * Creates an ExtensionUIContext implementation that bridges pi extension UI calls
@@ -12,7 +13,11 @@ import { sendSlotEvent, waitForSlotUiResponse } from './session-manager.js';
12
13
  * is updated and the bridge automatically routes to the new connection.
13
14
  * Pending UI promises survive reconnects and are replayed to the new client.
14
15
  */
15
- export function createExtensionUIBridge(slot, pushNotificationService) {
16
+ export function createExtensionUIBridge(slot, pushNotificationService, options) {
17
+ const isVoice = () => options?.isVoiceModeActive?.() ?? false;
18
+ function voiceDisabledError() {
19
+ return new Error(UI_BRIDGE_DISABLED_IN_VOICE_MODE);
20
+ }
16
21
  function notifyInteraction(method, fields) {
17
22
  if (!pushNotificationService)
18
23
  return;
@@ -72,24 +77,32 @@ export function createExtensionUIBridge(slot, pushNotificationService) {
72
77
  const ui = {
73
78
  // ---- Dialog methods (send + wait for response) ----
74
79
  async select(title, options, opts) {
80
+ if (isVoice())
81
+ throw voiceDisabledError();
75
82
  const requestId = crypto.randomUUID();
76
83
  const event = sendRequest(requestId, { method: 'select', title, options });
77
84
  notifyInteraction('select', { title, options });
78
85
  return dialogWithTimeout(requestId, event, opts, undefined);
79
86
  },
80
87
  async confirm(title, message, opts) {
88
+ if (isVoice())
89
+ throw voiceDisabledError();
81
90
  const requestId = crypto.randomUUID();
82
91
  const event = sendRequest(requestId, { method: 'confirm', title, message });
83
92
  notifyInteraction('confirm', { title, message });
84
93
  return dialogWithTimeout(requestId, event, opts, false);
85
94
  },
86
95
  async input(title, placeholder, opts) {
96
+ if (isVoice())
97
+ throw voiceDisabledError();
87
98
  const requestId = crypto.randomUUID();
88
99
  const event = sendRequest(requestId, { method: 'input', title, placeholder });
89
100
  notifyInteraction('input', { title });
90
101
  return dialogWithTimeout(requestId, event, opts, undefined);
91
102
  },
92
103
  async editor(title, prefill) {
104
+ if (isVoice())
105
+ throw voiceDisabledError();
93
106
  const requestId = crypto.randomUUID();
94
107
  const event = sendRequest(requestId, { method: 'editor', title, prefill });
95
108
  notifyInteraction('editor', { title });
@@ -8,6 +8,7 @@ import { PushNotificationService } from './push-notification.js';
8
8
  import { FilePushSubscriptionStore, WebPushSender, migratePushSubscriptionStore } from './push-infrastructure.js';
9
9
  import { LEGACY_PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_PUSH_SUBSCRIPTIONS_PATH, PIMOTE_SESSION_METADATA_PATH } from './paths.js';
10
10
  import { FileSessionMetadataStore } from './session-metadata.js';
11
+ import { buildVoiceOrchestrator } from './voice-orchestrator-boot.js';
11
12
  export async function main(options = {}) {
12
13
  let config = await loadConfig();
13
14
  config = await ensureVapidKeys(config);
@@ -23,7 +24,40 @@ export async function main(options = {}) {
23
24
  const sessionMetadataStore = new FileSessionMetadataStore(PIMOTE_SESSION_METADATA_PATH);
24
25
  await sessionMetadataStore.initialize();
25
26
  const sessionManager = new PimoteSessionManager(config, pushNotificationService);
26
- const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore);
27
+ // Build the voice orchestrator before createServer so each WsHandler can be
28
+ // handed a reference. The orchestrator needs a client-registry lookup, but
29
+ // the real registry is created inside createServer below — so we hand it a
30
+ // small forwarding shim whose backing map is swapped in after createServer
31
+ // returns (see review finding 6: previously a Proxy-over-Map).
32
+ const clientRegistryRef = { current: new Map() };
33
+ const voiceBoot = buildVoiceOrchestrator({
34
+ config,
35
+ sessionManager,
36
+ clientRegistry: {
37
+ get: (clientId) => clientRegistryRef.current.get(clientId),
38
+ },
39
+ });
40
+ const server = await createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceBoot.orchestrator);
41
+ clientRegistryRef.current = server.clientRegistry;
42
+ // Suppress push notifications for sessions currently owned by a voice call.
43
+ // The user is on the line — we don't need to ping their phone for idle
44
+ // signals or extension UI prompts. Pushes resume automatically once the
45
+ // call ends and `isCallActive` flips back to false.
46
+ pushNotificationService.setSuppressionPredicate((sessionId) => voiceBoot.orchestrator.isCallActive(sessionId));
47
+ // Tear down orchestrator bookkeeping when a session is being closed (idle
48
+ // reap, explicit close). Emits call_ended{server_ended} to the owner.
49
+ sessionManager.onBeforeSessionClose = async (sessionId) => {
50
+ if (!voiceBoot.orchestrator.isCallActive(sessionId))
51
+ return;
52
+ const slot = sessionManager.getSlot(sessionId);
53
+ const ownerClientId = slot?.connection?.connectedClientId;
54
+ await voiceBoot.orchestrator.endCall({ sessionId, reason: 'server_ended' });
55
+ if (ownerClientId) {
56
+ const handler = server.clientRegistry.get(ownerClientId);
57
+ handler?.sendCallEndedEvent(sessionId, 'server_ended');
58
+ }
59
+ };
60
+ await voiceBoot.orchestrator.start();
27
61
  // Start idle session reaping with client connectivity check
28
62
  sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
29
63
  await server.start(port);
@@ -36,6 +70,7 @@ export async function main(options = {}) {
36
70
  // Graceful shutdown
37
71
  const shutdown = async () => {
38
72
  console.log('\n[pimote] Shutting down...');
73
+ await voiceBoot.shutdown();
39
74
  await sessionManager.dispose();
40
75
  await server.close();
41
76
  process.exit(0);
@@ -55,13 +55,36 @@ export function extractMessageEntryIds(branch) {
55
55
  }
56
56
  return ids;
57
57
  }
58
+ /**
59
+ * True when the message is pi-agent-core's synthetic aborted placeholder
60
+ * (pushed into agent.state.messages on session.abort() but never persisted
61
+ * via message_end). Identifying these lets entryId alignment skip over them.
62
+ */
63
+ export function isAbortedPlaceholderMessage(msg) {
64
+ if (msg.role !== 'assistant')
65
+ return false;
66
+ if (msg.aborted !== true)
67
+ return false;
68
+ return msg.content.every((c) => c.type === 'text' && !c.text);
69
+ }
58
70
  /**
59
71
  * Apply entry IDs from the session manager onto mapped messages.
60
- * Zips by index — both arrays must correspond 1:1.
72
+ *
73
+ * Subtle alignment: `messages` comes from `agent.state.messages`, which
74
+ * includes pi-agent-core's synthetic aborted placeholders (abort pushes an
75
+ * empty assistant into state but never persists an entry for it).
76
+ * `entryIds` comes from persisted session entries, which do NOT include
77
+ * those placeholders. We walk the messages and skip aborted placeholders
78
+ * so the persisted IDs land on the correct real messages.
61
79
  */
62
80
  export function applyEntryIds(messages, entryIds) {
63
- for (let i = 0; i < messages.length && i < entryIds.length; i++) {
64
- messages[i].entryId = entryIds[i];
81
+ let idIdx = 0;
82
+ for (let i = 0; i < messages.length; i++) {
83
+ if (isAbortedPlaceholderMessage(messages[i]))
84
+ continue;
85
+ if (idIdx >= entryIds.length)
86
+ break;
87
+ messages[i].entryId = entryIds[idIdx++];
65
88
  }
66
89
  }
67
90
  export function mapAgentMessage(msg) {
@@ -104,8 +127,12 @@ export function mapAgentMessage(msg) {
104
127
  console.warn('[message-mapper] Content value:', msg.content);
105
128
  content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
106
129
  }
107
- // Log empty content for debugging
108
- if (content.length === 0) {
130
+ // Aborted assistant turns are a real signal in voice mode (every barge-in
131
+ // produces one via pi-agent-core's handleRunFailure) and shouldn't be
132
+ // confused with malformed messages. Log the empty-content warning only
133
+ // when it's NOT an expected aborted turn.
134
+ const aborted = role === 'assistant' && msg.stopReason === 'aborted';
135
+ if (content.length === 0 && !aborted) {
109
136
  console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
110
137
  }
111
138
  // Handle custom messages — preserve customType and display flag for the client
@@ -139,5 +166,10 @@ export function mapAgentMessage(msg) {
139
166
  // Note: msg.id is typically undefined for standard SDK messages (UserMessage,
140
167
  // AssistantMessage, ToolResultMessage). Entry IDs are applied separately via
141
168
  // applyEntryIds() using the session manager's branch entries.
142
- return { role, content, ...(msg.id ? { entryId: msg.id } : {}) };
169
+ return {
170
+ role,
171
+ content,
172
+ ...(msg.id ? { entryId: msg.id } : {}),
173
+ ...(aborted ? { aborted: true } : {}),
174
+ };
143
175
  }
@@ -2,10 +2,18 @@ export class PushNotificationService {
2
2
  sender;
3
3
  store;
4
4
  subscriptions = [];
5
+ suppressionPredicate;
5
6
  constructor(sender, store) {
6
7
  this.sender = sender;
7
8
  this.store = store;
8
9
  }
10
+ /** Install a predicate that suppresses notifications for a given session.
11
+ * Used to silence pushes while a voice call owns the session — pushes
12
+ * resume automatically once the predicate stops returning true (call
13
+ * hangs up). Pass `undefined` to clear. */
14
+ setSuppressionPredicate(predicate) {
15
+ this.suppressionPredicate = predicate;
16
+ }
9
17
  /** Load subscriptions from store on startup */
10
18
  async initialize() {
11
19
  this.subscriptions = await this.store.load();
@@ -35,6 +43,9 @@ export class PushNotificationService {
35
43
  }
36
44
  /** Send push notification to all subscriptions */
37
45
  async notify(payload) {
46
+ if (this.suppressionPredicate?.(payload.sessionId)) {
47
+ return;
48
+ }
38
49
  const expiredEndpoints = [];
39
50
  const payloadStr = JSON.stringify(payload);
40
51
  for (const sub of this.subscriptions) {
@@ -79,7 +79,7 @@ async function serveFallback(res) {
79
79
  res.end(JSON.stringify({ error: 'not found' }));
80
80
  }
81
81
  }
82
- export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore) {
82
+ export async function createServer(config, sessionManager, folderIndex, pushNotificationService, sessionMetadataStore, voiceOrchestrator) {
83
83
  const clientVersion = await loadClientVersion();
84
84
  if (clientVersion) {
85
85
  console.log(`[pimote] Client build version: ${clientVersion}`);
@@ -157,7 +157,7 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
157
157
  // The close handler skips cleanup when the registry already points to a
158
158
  // different handler, so this is the only place it runs.
159
159
  const existing = clientRegistry.get(clientId);
160
- const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
160
+ const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator);
161
161
  clientRegistry.set(clientId, handler);
162
162
  if (existing) {
163
163
  existing.cleanup();
@@ -2,6 +2,8 @@ import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessi
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
4
  import { getGitBranch } from './git-branch.js';
5
+ import { createVoiceExtension } from './voice/index.js';
6
+ import { autoDrainOnAbort } from './auto-drain-on-abort.js';
5
7
  // ---- Slot-based helpers (operate on ManagedSlot) ----
6
8
  /** Send an event to the client connected to this slot. No-op if disconnected. */
7
9
  export function sendSlotEvent(slot, event) {
@@ -53,7 +55,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
53
55
  eventBuffer,
54
56
  status: session.isStreaming ? 'working' : 'idle',
55
57
  needsAttention: false,
56
- lastActivity: Date.now(),
58
+ idleSince: session.isStreaming ? null : Date.now(),
57
59
  unsubscribe: () => { },
58
60
  pendingUiResponses: new Map(),
59
61
  extensionsBound: false,
@@ -66,14 +68,23 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
66
68
  const unsubscribe = session.subscribe((event) => {
67
69
  if (event.type === 'agent_start' && state.status !== 'working') {
68
70
  state.status = 'working';
71
+ state.idleSince = null;
69
72
  callbacks.onStatusChange?.(sessionId, folderPath);
70
73
  }
71
74
  else if (event.type === 'agent_end' && state.status !== 'idle') {
72
75
  state.status = 'idle';
76
+ state.idleSince = Date.now();
73
77
  state.needsAttention = true;
74
78
  if (slotRef.slot)
75
79
  callbacks.onAgentEnd?.(sessionId, slotRef.slot);
76
80
  callbacks.onStatusChange?.(sessionId, folderPath);
81
+ // If the run ended via abort and there are queued steering /
82
+ // follow-up messages, drain them — pi-agent-core's runLoop skips
83
+ // its trailing queue poll on the abort exit path, so without this
84
+ // queued messages would sit until the next prompt() call. Universal
85
+ // across pimote (not voice-specific) so typed-mode users also
86
+ // benefit. See `auto-drain-on-abort.ts` for rationale.
87
+ void autoDrainOnAbort(session, event.messages[event.messages.length - 1]);
77
88
  }
78
89
  eventBuffer.onEvent(event, sessionId, (e) => callbacks.sendEvent(e), () => session.messages[session.messages.length - 1]);
79
90
  });
@@ -132,18 +143,43 @@ export class PimoteSessionManager {
132
143
  onStatusChange;
133
144
  onSessionClosed;
134
145
  onGitBranchChange;
146
+ /** Fired synchronously before a session's state is torn down (e.g. idle
147
+ * reap, explicit close). Consumers use this to drop external bookkeeping
148
+ * (e.g. `VoiceOrchestrator.endCall`) while the session is still addressable. */
149
+ onBeforeSessionClose;
135
150
  constructor(config, pushNotificationService) {
136
151
  this.config = config;
137
152
  this.pushNotificationService = pushNotificationService;
138
153
  this.authStorage = AuthStorage.create();
139
154
  this.modelRegistry = ModelRegistry.create(this.authStorage);
140
155
  }
156
+ /**
157
+ * Build the voice extension factory for this server's config, if possible.
158
+ * Returns undefined (and logs a warning) when neither voice-specific model
159
+ * refs nor fallback defaultProvider/defaultModel are configured — existing
160
+ * non-voice deployments continue to work unchanged.
161
+ */
162
+ buildVoiceExtensionFactory() {
163
+ const interpreter = this.config.defaultInterpreterModel ??
164
+ (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
165
+ const worker = this.config.defaultWorkerModel ??
166
+ (this.config.defaultProvider && this.config.defaultModel ? { provider: this.config.defaultProvider, modelId: this.config.defaultModel } : undefined);
167
+ if (!interpreter || !worker) {
168
+ console.warn('[pimote] voice extension disabled: no defaultInterpreterModel/defaultWorkerModel or fallback defaultProvider/defaultModel in config');
169
+ return undefined;
170
+ }
171
+ return createVoiceExtension({
172
+ defaultInterpreterModel: interpreter,
173
+ defaultWorkerModel: worker,
174
+ });
175
+ }
141
176
  async openSession(folderPath, sessionFilePath) {
142
177
  const eventBusRef = { current: null };
143
178
  const sharedAuthStorage = this.authStorage;
144
179
  const sharedModelRegistry = this.modelRegistry;
145
180
  const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
146
181
  const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
182
+ const voiceExtensionFactory = this.buildVoiceExtensionFactory();
147
183
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
148
184
  const eventBus = createEventBus();
149
185
  eventBusRef.current = eventBus;
@@ -152,7 +188,10 @@ export class PimoteSessionManager {
152
188
  agentDir,
153
189
  authStorage: sharedAuthStorage,
154
190
  modelRegistry: sharedModelRegistry,
155
- resourceLoaderOptions: { eventBus },
191
+ resourceLoaderOptions: {
192
+ eventBus,
193
+ ...(voiceExtensionFactory ? { extensionFactories: [voiceExtensionFactory] } : {}),
194
+ },
156
195
  });
157
196
  return {
158
197
  ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
@@ -187,6 +226,12 @@ export class PimoteSessionManager {
187
226
  session.setThinkingLevel(this.config.defaultThinkingLevel);
188
227
  console.log(`[pimote] Set default thinking level: ${this.config.defaultThinkingLevel}`);
189
228
  }
229
+ // Pimote convention: drain the entire steering / follow-up queue into a
230
+ // single consolidated run rather than one message per run. Matches the
231
+ // UX expectation that queued messages are delivered together when the
232
+ // agent next processes, not metered out across multiple turns.
233
+ session.setSteeringMode('all');
234
+ session.setFollowUpMode('all');
190
235
  // Create the slot object. Use a slotRef so createSessionState callbacks can reference the slot.
191
236
  const slotRef = { slot: null };
192
237
  const sessionState = createSessionState(session, eventBusRef.current, this.config, {
@@ -264,6 +309,14 @@ export class PimoteSessionManager {
264
309
  const slot = this.sessions.get(sessionId);
265
310
  if (!slot)
266
311
  return;
312
+ if (this.onBeforeSessionClose) {
313
+ try {
314
+ await this.onBeforeSessionClose(sessionId, slot.folderPath);
315
+ }
316
+ catch (err) {
317
+ console.warn('[pimote] onBeforeSessionClose threw:', err);
318
+ }
319
+ }
267
320
  teardownSessionState(slot.sessionState);
268
321
  slot.eventBusRef.current?.clear();
269
322
  const folderPath = slot.folderPath;
@@ -281,9 +334,15 @@ export class PimoteSessionManager {
281
334
  this.lastKnownGitBranchBySession.set(newId, lastKnown);
282
335
  }
283
336
  /** Rebuild a slot's SessionState after session replacement.
284
- * Tears down the old state and creates a new one from the current runtime.session. */
337
+ * Tears down the old state and creates a new one from the current runtime.session.
338
+ * Also refreshes slot.folderPath from the new session's header cwd, since fork-from
339
+ * (e.g. the worktree extension) can rebind the slot to a session whose cwd differs
340
+ * from the original. */
285
341
  rebuildSessionState(slot) {
286
342
  teardownSessionState(slot.sessionState);
343
+ const newCwd = slot.runtime.session.sessionManager.getCwd();
344
+ if (newCwd)
345
+ slot.folderPath = newCwd;
287
346
  const slotRef = { slot: slot };
288
347
  slot.sessionState = createSessionState(slot.runtime.session, slot.eventBusRef.current, this.config, {
289
348
  onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
@@ -291,6 +350,11 @@ export class PimoteSessionManager {
291
350
  sendEvent: (e) => sendSlotEvent(slot, e),
292
351
  }, slotRef, slot.folderPath);
293
352
  }
353
+ /** Alias for getSession that returns null (not undefined) for consumers
354
+ * that expect nullable pointers (e.g. VoiceSessionBusResolver). */
355
+ getSlot(sessionId) {
356
+ return this.sessions.get(sessionId) ?? null;
357
+ }
294
358
  getSession(sessionId) {
295
359
  return this.sessions.get(sessionId);
296
360
  }
@@ -306,7 +370,11 @@ export class PimoteSessionManager {
306
370
  }
307
371
  const clientId = slot.connection?.connectedClientId ?? null;
308
372
  const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
309
- if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
373
+ // Only idle (non-streaming) sessions are eligible for reaping. `idleSince` is set on
374
+ // `agent_end` and cleared on `agent_start`, so a working session can never be reaped
375
+ // here, regardless of how long it's been since a client was connected.
376
+ const idleSince = slot.sessionState.idleSince;
377
+ if (!hasConnectedClient && idleSince !== null && Date.now() - idleSince > idleTimeout) {
310
378
  this.closeSession(sessionId).catch(() => {
311
379
  // Best-effort cleanup — swallow errors during idle reaping
312
380
  });
@@ -0,0 +1,6 @@
1
+ // Actions emitted by the voice FSM reducer.
2
+ //
3
+ // The reducer is pure: every side effect is encoded as one of these
4
+ // values. The shell in `index.ts` interprets each into an actual call
5
+ // against the pi SDK / speechmux WS / EventBus.
6
+ export {};
@@ -0,0 +1,7 @@
1
+ // Events the voice FSM consumes.
2
+ //
3
+ // Every external stimulus (EventBus, WS lifecycle, WS frames, SDK hooks)
4
+ // is normalized into one of these typed events before reaching the
5
+ // reducer. The shell in `index.ts` is the only place where ad-hoc input
6
+ // translation lives.
7
+ export {};
@@ -0,0 +1,74 @@
1
+ // Top-level voice FSM reducer.
2
+ //
3
+ // Folds the three sub-reducers (lifecycle, streaming, walkback) into a
4
+ // single transition. Every event is fanned out to the sub-reducers
5
+ // that care about it; results are merged.
6
+ //
7
+ // The sub-reducers don't know about each other. Two cross-cutting bits
8
+ // are handled here:
9
+ //
10
+ // 1. Frame buffering. The streaming reducer emits raw `OutgoingFrame`
11
+ // values; we route each through `bufferOrPassFrame` against the
12
+ // current lifecycle state to either forward (Active) or buffer
13
+ // (Activating). The buffered case mutates the lifecycle slice; the
14
+ // forwarded case becomes a `send_frame` action.
15
+ //
16
+ // 2. The `ws:incoming` `user` frame turns into a `send_user_message`
17
+ // action — and only when lifecycle is `active`. (The walkback
18
+ // reducer intentionally ignores user frames; this is the right
19
+ // place to handle them because it lives at the boundary between
20
+ // "do we even have a connection" and "what should the agent do".)
21
+ import { reduceLifecycle, applyLifecycleResult, bufferOrPassFrame } from './reducers/lifecycle.js';
22
+ import { reduceStreaming } from './reducers/streaming.js';
23
+ import { reduceWalkback, applyWalkbackResult } from './reducers/walkback.js';
24
+ export function reduce(prev, event, reducers) {
25
+ let state = prev;
26
+ const actions = [];
27
+ // ---- Lifecycle ---------------------------------------------------------
28
+ const life = reduceLifecycle(state.lifecycle, event, {
29
+ interpreterApplied: state.interpreterApplied,
30
+ config: reducers.config,
31
+ });
32
+ state = applyLifecycleResult(state, life);
33
+ actions.push(...life.actions);
34
+ // ---- Streaming ---------------------------------------------------------
35
+ // Only meaningful when activating or active. Dormant means we can't
36
+ // emit frames anywhere, so skip the work and any accidental frames.
37
+ if (state.lifecycle.kind !== 'dormant') {
38
+ const stream = reduceStreaming(state.message, event);
39
+ state = { ...state, message: stream.next };
40
+ for (const frame of stream.frames) {
41
+ const routed = bufferOrPassFrame(state.lifecycle, frame);
42
+ state = { ...state, lifecycle: routed.next };
43
+ actions.push(...routed.actions);
44
+ }
45
+ // Fold the latest ended speak id into runtime so walkback has a
46
+ // fallback target if speechmux's rollback/abort doesn't echo a
47
+ // speak_id (e.g. older speechmux build).
48
+ if (stream.endedSpeakIds.length > 0) {
49
+ state = {
50
+ ...state,
51
+ lastEmittedSpeakId: stream.endedSpeakIds[stream.endedSpeakIds.length - 1] ?? state.lastEmittedSpeakId,
52
+ };
53
+ }
54
+ }
55
+ // ---- Walkback ----------------------------------------------------------
56
+ const wb = reduceWalkback(state.walkback, state.lastEmittedSpeakId, event);
57
+ state = applyWalkbackResult(state, wb);
58
+ actions.push(...wb.actions);
59
+ // Clear lastEmittedSpeakId on full deactivation so a subsequent call
60
+ // doesn't carry it over.
61
+ if (event.type === 'eb:deactivate') {
62
+ state = { ...state, lastEmittedSpeakId: null };
63
+ }
64
+ // ---- Cross-cutting: incoming `user` frame → sendUserMessage ------------
65
+ if (event.type === 'ws:incoming' && event.frame.type === 'user') {
66
+ if (state.lifecycle.kind === 'active') {
67
+ actions.push({ kind: 'send_user_message', text: event.frame.text });
68
+ }
69
+ // If lifecycle isn't active, drop. The shell will log this — it
70
+ // means a user frame arrived after we tore down (or before we
71
+ // wired the WS), both of which are bugs upstream.
72
+ }
73
+ return { next: state, actions };
74
+ }