@pimote/pimote 0.1.1 → 0.3.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 (61) hide show
  1. package/README.md +46 -17
  2. package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
  3. package/client/build/_app/immutable/assets/2.D9fiCd8W.css +1 -0
  4. package/client/build/_app/immutable/chunks/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
  5. package/client/build/_app/immutable/chunks/BNqgidwO.js +5 -0
  6. package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
  7. package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
  8. package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
  9. package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
  10. package/client/build/_app/immutable/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
  11. package/client/build/_app/immutable/entry/app.DO-zgzyy.js +2 -0
  12. package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
  13. package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
  14. package/client/build/_app/immutable/nodes/1.B2l9JGRO.js +1 -0
  15. package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
  16. package/client/build/_app/version.json +1 -1
  17. package/client/build/index.html +8 -8
  18. package/package.json +9 -5
  19. package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
  20. package/server/dist/auto-drain-on-abort.js +49 -0
  21. package/server/dist/config.js +21 -0
  22. package/server/dist/extension-ui-bridge.js +14 -1
  23. package/server/dist/folder-index.js +8 -4
  24. package/server/dist/git-branch.js +32 -0
  25. package/server/dist/index.js +31 -1
  26. package/server/dist/message-mapper.js +99 -4
  27. package/server/dist/server.js +5 -2
  28. package/server/dist/session-manager.js +99 -6
  29. package/server/dist/voice/fsm/actions.js +6 -0
  30. package/server/dist/voice/fsm/events.js +7 -0
  31. package/server/dist/voice/fsm/reducer.js +74 -0
  32. package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
  33. package/server/dist/voice/fsm/reducers/streaming.js +220 -0
  34. package/server/dist/voice/fsm/reducers/walkback.js +73 -0
  35. package/server/dist/voice/fsm/state.js +21 -0
  36. package/server/dist/voice/fsm/text-extractor.js +128 -0
  37. package/server/dist/voice/index.js +319 -0
  38. package/server/dist/voice/interpreter-prompt.js +115 -0
  39. package/server/dist/voice/speechmux-client.js +153 -0
  40. package/server/dist/voice/state-machine.js +7 -0
  41. package/server/dist/voice/wait-for-idle.js +67 -0
  42. package/server/dist/voice/walk-back.js +198 -0
  43. package/server/dist/voice-orchestrator-boot.js +90 -0
  44. package/server/dist/voice-orchestrator.js +91 -0
  45. package/server/dist/ws-handler.js +340 -36
  46. package/shared/dist/index.d.ts +1 -0
  47. package/shared/dist/index.js +2 -0
  48. package/shared/dist/protocol.d.ts +614 -0
  49. package/shared/dist/protocol.js +30 -0
  50. package/client/build/_app/immutable/assets/0.Cj7UL9cq.css +0 -2
  51. package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
  52. package/client/build/_app/immutable/chunks/BEKHoMUP.js +0 -1
  53. package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
  54. package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
  55. package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
  56. package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
  57. package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
  58. package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
  59. package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
  60. package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
  61. package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pimote/pimote",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
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,8 +29,10 @@
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
- "patches/@mariozechner+pi-coding-agent+0.65.0.patch",
35
+ "patches/@mariozechner+pi-coding-agent+0.67.6.patch",
34
36
  "README.md",
35
37
  "LICENSE"
36
38
  ],
@@ -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 .",
@@ -73,7 +75,9 @@
73
75
  },
74
76
  "dependencies": {
75
77
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
76
- "@mariozechner/pi-coding-agent": "0.65.0",
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"
@@ -1,8 +1,8 @@
1
1
  diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
2
- index 9ab786e..6f89829 100644
2
+ index fcb3466..20ee304 100644
3
3
  --- a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
4
4
  +++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session-runtime.js
5
- @@ -74,9 +74,6 @@ export class AgentSessionRuntime {
5
+ @@ -75,9 +75,6 @@ export class AgentSessionRuntime {
6
6
  this.session.dispose();
7
7
  }
8
8
  apply(result) {
@@ -12,9 +12,9 @@ index 9ab786e..6f89829 100644
12
12
  this._session = result.session;
13
13
  this._services = result.services;
14
14
  this._diagnostics = result.diagnostics;
15
- @@ -223,9 +220,6 @@ export class AgentSessionRuntime {
16
- */
15
+ @@ -227,9 +224,6 @@ export class AgentSessionRuntime {
17
16
  export async function createAgentSessionRuntime(createRuntime, options) {
17
+ assertSessionCwdExists(options.sessionManager, options.cwd);
18
18
  const result = await createRuntime(options);
19
19
  - if (process.cwd() !== result.services.cwd) {
20
20
  - process.chdir(result.services.cwd);
@@ -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 });
@@ -7,9 +7,13 @@ const PROJECT_MARKERS = ['.git', 'package.json'];
7
7
  * Scans configured root directories for project folders and lists their sessions.
8
8
  */
9
9
  export class FolderIndex {
10
- roots;
11
- constructor(roots) {
12
- this.roots = roots;
10
+ _roots;
11
+ constructor(_roots) {
12
+ this._roots = _roots;
13
+ }
14
+ /** Returns the configured root directories. */
15
+ get roots() {
16
+ return this._roots;
13
17
  }
14
18
  /**
15
19
  * Scan all roots one level deep for project directories.
@@ -17,7 +21,7 @@ export class FolderIndex {
17
21
  */
18
22
  async scan() {
19
23
  const folders = [];
20
- for (const root of this.roots) {
24
+ for (const root of this._roots) {
21
25
  let entries;
22
26
  try {
23
27
  entries = await readdir(root);
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ /** Resolve the current git branch for a directory. Returns null if not a git repo or detached. */
3
+ export function getGitBranch(cwd) {
4
+ // Guard against inherited Git env vars forcing resolution to another repo.
5
+ const env = { ...process.env };
6
+ delete env.GIT_DIR;
7
+ delete env.GIT_WORK_TREE;
8
+ const runGit = (args) => {
9
+ try {
10
+ const value = execFileSync('git', args, {
11
+ cwd,
12
+ env,
13
+ encoding: 'utf-8',
14
+ timeout: 2000,
15
+ stdio: ['ignore', 'pipe', 'ignore'],
16
+ }).trim();
17
+ return value || null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ };
23
+ // Best signal for the checked-out branch (works with linked worktrees).
24
+ const current = runGit(['branch', '--show-current']);
25
+ if (current)
26
+ return current;
27
+ // Fallback for older Git versions / unusual setups.
28
+ const abbrevRef = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
29
+ if (!abbrevRef || abbrevRef === 'HEAD')
30
+ return null;
31
+ return abbrevRef;
32
+ }
@@ -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,35 @@ 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
+ // Tear down orchestrator bookkeeping when a session is being closed (idle
43
+ // reap, explicit close). Emits call_ended{server_ended} to the owner.
44
+ sessionManager.onBeforeSessionClose = async (sessionId) => {
45
+ if (!voiceBoot.orchestrator.isCallActive(sessionId))
46
+ return;
47
+ const slot = sessionManager.getSlot(sessionId);
48
+ const ownerClientId = slot?.connection?.connectedClientId;
49
+ await voiceBoot.orchestrator.endCall({ sessionId, reason: 'server_ended' });
50
+ if (ownerClientId) {
51
+ const handler = server.clientRegistry.get(ownerClientId);
52
+ handler?.sendCallEndedEvent(sessionId, 'server_ended');
53
+ }
54
+ };
55
+ await voiceBoot.orchestrator.start();
27
56
  // Start idle session reaping with client connectivity check
28
57
  sessionManager.startIdleCheck(config.idleTimeout, (clientId) => server.clientRegistry.has(clientId));
29
58
  await server.start(port);
@@ -36,6 +65,7 @@ export async function main(options = {}) {
36
65
  // Graceful shutdown
37
66
  const shutdown = async () => {
38
67
  console.log('\n[pimote] Shutting down...');
68
+ await voiceBoot.shutdown();
39
69
  await sessionManager.dispose();
40
70
  await server.close();
41
71
  process.exit(0);
@@ -6,6 +6,87 @@
6
6
  export function mapAgentMessages(messages) {
7
7
  return messages.map(mapAgentMessage);
8
8
  }
9
+ /**
10
+ * Extract entry IDs from branch entries in the same order that
11
+ * buildSessionContext produces messages. This mirrors the SDK's
12
+ * compaction/branch-summary logic so IDs can be zipped 1:1 with
13
+ * the mapped PimoteAgentMessage array.
14
+ */
15
+ export function extractMessageEntryIds(branch) {
16
+ // Find the last compaction entry on the path
17
+ let compaction = null;
18
+ for (const entry of branch) {
19
+ if (entry.type === 'compaction')
20
+ compaction = entry;
21
+ }
22
+ const ids = [];
23
+ const appendId = (entry) => {
24
+ if (entry.type === 'message') {
25
+ ids.push(entry.id);
26
+ }
27
+ else if (entry.type === 'custom_message') {
28
+ ids.push(entry.id);
29
+ }
30
+ else if (entry.type === 'branch_summary' && entry.summary) {
31
+ ids.push(entry.id);
32
+ }
33
+ };
34
+ if (compaction) {
35
+ // Compaction summary message maps to the compaction entry
36
+ ids.push(compaction.id);
37
+ const compactionIdx = branch.findIndex((e) => e.type === 'compaction' && e.id === compaction.id);
38
+ // Kept messages before the compaction entry
39
+ let foundFirstKept = false;
40
+ for (let i = 0; i < compactionIdx; i++) {
41
+ if (branch[i].id === compaction.firstKeptEntryId)
42
+ foundFirstKept = true;
43
+ if (foundFirstKept)
44
+ appendId(branch[i]);
45
+ }
46
+ // Messages after the compaction entry
47
+ for (let i = compactionIdx + 1; i < branch.length; i++) {
48
+ appendId(branch[i]);
49
+ }
50
+ }
51
+ else {
52
+ for (const entry of branch) {
53
+ appendId(entry);
54
+ }
55
+ }
56
+ return ids;
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
+ }
70
+ /**
71
+ * Apply entry IDs from the session manager onto mapped messages.
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.
79
+ */
80
+ export function applyEntryIds(messages, entryIds) {
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++];
88
+ }
89
+ }
9
90
  export function mapAgentMessage(msg) {
10
91
  const role = msg.role ?? 'unknown';
11
92
  const content = [];
@@ -46,13 +127,17 @@ export function mapAgentMessage(msg) {
46
127
  console.warn('[message-mapper] Content value:', msg.content);
47
128
  content.push({ type: 'text', text: `[Unexpected content type: ${typeof msg.content}]` });
48
129
  }
49
- // Log empty content for debugging
50
- 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) {
51
136
  console.warn('[message-mapper] Empty content array for message:', { role, content: msg.content });
52
137
  }
53
138
  // Handle custom messages — preserve customType and display flag for the client
54
139
  if (role === 'custom') {
55
- return { role, content, customType: msg.customType, display: msg.display ?? true };
140
+ return { role, content, entryId: msg.id, customType: msg.customType, display: msg.display ?? true };
56
141
  }
57
142
  // Handle tool result messages
58
143
  if (role === 'toolResult') {
@@ -72,9 +157,19 @@ export function mapAgentMessage(msg) {
72
157
  toolCallId: msg.toolCallId,
73
158
  toolName: msg.toolName,
74
159
  result: resultContent.length > 0 ? resultContent[0].text : undefined,
160
+ isError: msg.isError || undefined,
75
161
  },
76
162
  ],
163
+ entryId: msg.id,
77
164
  };
78
165
  }
79
- return { role, content };
166
+ // Note: msg.id is typically undefined for standard SDK messages (UserMessage,
167
+ // AssistantMessage, ToolResultMessage). Entry IDs are applied separately via
168
+ // applyEntryIds() using the session manager's branch entries.
169
+ return {
170
+ role,
171
+ content,
172
+ ...(msg.id ? { entryId: msg.id } : {}),
173
+ ...(aborted ? { aborted: true } : {}),
174
+ };
80
175
  }
@@ -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}`);
@@ -121,6 +121,9 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
121
121
  sessionManager.onSessionClosed = (sessionId, folderPath) => {
122
122
  WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
123
123
  };
124
+ sessionManager.onGitBranchChange = (sessionId, folderPath) => {
125
+ WsHandler.broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry);
126
+ };
124
127
  const wss = new WebSocketServer({ noServer: true });
125
128
  const clientRegistry = new Map();
126
129
  httpServer.on('upgrade', (req, socket, head) => {
@@ -154,7 +157,7 @@ export async function createServer(config, sessionManager, folderIndex, pushNoti
154
157
  // The close handler skips cleanup when the registry already points to a
155
158
  // different handler, so this is the only place it runs.
156
159
  const existing = clientRegistry.get(clientId);
157
- const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry);
160
+ const handler = new WsHandler(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator);
158
161
  clientRegistry.set(clientId, handler);
159
162
  if (existing) {
160
163
  existing.cleanup();