@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
@@ -1,9 +1,14 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { join, sep } from 'node:path';
4
+ import { promisify } from 'node:util';
2
5
  import { resolveAllSlotPendingUi, resolveSlotPendingUi, replaySlotPendingUiRequests } from './session-manager.js';
3
6
  import { getMergedPanelCards } from './panel-state.js';
4
7
  import { createExtensionUIBridge } from './extension-ui-bridge.js';
5
8
  import { findExternalPiProcesses, killExternalPiProcesses } from './takeover.js';
6
- import { mapAgentMessages } from './message-mapper.js';
9
+ import { mapAgentMessages, extractMessageEntryIds, applyEntryIds } from './message-mapper.js';
10
+ import { getGitBranch } from './git-branch.js';
11
+ import { CallBindError } from './voice-orchestrator.js';
7
12
  /** Parse data-URL encoded images into the shape the pi SDK expects. */
8
13
  function parseDataUrlImages(images) {
9
14
  if (!images || images.length === 0)
@@ -15,6 +20,66 @@ function parseDataUrlImages(images) {
15
20
  return { type: 'image', data: match[2], mimeType: match[1] };
16
21
  });
17
22
  }
23
+ const TREE_PREVIEW_MAX_CHARS = 200;
24
+ function truncatePreview(value) {
25
+ if (value.length <= TREE_PREVIEW_MAX_CHARS)
26
+ return value;
27
+ return `${value.slice(0, TREE_PREVIEW_MAX_CHARS - 3)}...`;
28
+ }
29
+ function textFromEntryContent(content) {
30
+ if (typeof content === 'string')
31
+ return content;
32
+ if (!Array.isArray(content))
33
+ return '';
34
+ return content
35
+ .filter((block) => {
36
+ if (!block || typeof block !== 'object')
37
+ return false;
38
+ const candidate = block;
39
+ return candidate.type === 'text' && typeof candidate.text === 'string';
40
+ })
41
+ .map((block) => block.text)
42
+ .join('\n');
43
+ }
44
+ function previewForEntry(entry) {
45
+ if (entry.type === 'message') {
46
+ const messageContent = entry.message?.content;
47
+ const text = textFromEntryContent(messageContent);
48
+ return truncatePreview(text || entry.type);
49
+ }
50
+ if (entry.type === 'custom_message') {
51
+ const text = textFromEntryContent(entry.content);
52
+ return truncatePreview(text || entry.type);
53
+ }
54
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') {
55
+ const summary = typeof entry.summary === 'string' ? entry.summary : '';
56
+ return truncatePreview(summary || entry.type);
57
+ }
58
+ return entry.type;
59
+ }
60
+ /** Map pi SDK tree nodes to the wire transfer shape used by pimote clients. */
61
+ export function mapTreeNodes(nodes) {
62
+ return nodes.map((node) => {
63
+ const timestamp = typeof node.entry.timestamp === 'string' ? node.entry.timestamp : new Date(0).toISOString();
64
+ return {
65
+ id: node.entry.id,
66
+ type: node.entry.type,
67
+ role: node.entry.type === 'message'
68
+ ? typeof node.entry.message.role === 'string'
69
+ ? node.entry.message.role
70
+ : undefined
71
+ : 'role' in node.entry && typeof node.entry.role === 'string'
72
+ ? node.entry.role
73
+ : undefined,
74
+ customType: 'customType' in node.entry && typeof node.entry.customType === 'string' ? node.entry.customType : undefined,
75
+ preview: previewForEntry(node.entry),
76
+ timestamp,
77
+ label: node.label,
78
+ labelTimestamp: node.labelTimestamp,
79
+ children: mapTreeNodes(node.children),
80
+ };
81
+ });
82
+ }
18
83
  /**
19
84
  * Create command context actions for extension commands.
20
85
  * Captures the ManagedSlot (stable lifetime), not a transient handler.
@@ -62,20 +127,6 @@ function createCommandContextActions(slot) {
62
127
  reload: () => slot.session.reload(),
63
128
  };
64
129
  }
65
- /** Resolve the current git branch for a directory. Returns null if not a git repo. */
66
- function getGitBranch(cwd) {
67
- try {
68
- return (execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
69
- cwd,
70
- encoding: 'utf-8',
71
- timeout: 2000,
72
- stdio: ['ignore', 'pipe', 'ignore'],
73
- }).trim() || null);
74
- }
75
- catch {
76
- return null;
77
- }
78
- }
79
130
  export class WsHandler {
80
131
  sessionManager;
81
132
  folderIndex;
@@ -83,16 +134,18 @@ export class WsHandler {
83
134
  pushNotificationService;
84
135
  sessionMetadataStore;
85
136
  clientRegistry;
137
+ voiceOrchestrator;
86
138
  subscribedSessions = new Set();
87
139
  viewedSessionId = null;
88
140
  clientId;
89
- constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry) {
141
+ constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator) {
90
142
  this.sessionManager = sessionManager;
91
143
  this.folderIndex = folderIndex;
92
144
  this.ws = ws;
93
145
  this.pushNotificationService = pushNotificationService;
94
146
  this.sessionMetadataStore = sessionMetadataStore;
95
147
  this.clientRegistry = clientRegistry;
148
+ this.voiceOrchestrator = voiceOrchestrator;
96
149
  this.clientId = clientId;
97
150
  }
98
151
  getViewedSessionId() {
@@ -131,7 +184,49 @@ export class WsHandler {
131
184
  folder.activeStatus = null;
132
185
  }
133
186
  }
134
- this.sendResponse(id, true, { folders });
187
+ this.sendResponse(id, true, { folders, roots: this.folderIndex.roots });
188
+ break;
189
+ }
190
+ case 'create_project': {
191
+ const name = command.name;
192
+ const root = command.root;
193
+ // Validate name: non-empty, no path separators, not . or ..
194
+ if (!name || name.includes('/') || name.includes(sep) || name === '.' || name === '..') {
195
+ this.sendResponse(id, false, undefined, 'Invalid project name');
196
+ break;
197
+ }
198
+ // Validate root is one of the configured roots
199
+ if (!this.folderIndex.roots.includes(root)) {
200
+ this.sendResponse(id, false, undefined, 'Root is not a configured project root');
201
+ break;
202
+ }
203
+ const folderPath = join(root, name);
204
+ // Check if directory already exists
205
+ try {
206
+ await stat(folderPath);
207
+ this.sendResponse(id, false, undefined, 'Directory already exists');
208
+ break;
209
+ }
210
+ catch (err) {
211
+ const code = err instanceof Error ? err.code : undefined;
212
+ if (code !== 'ENOENT') {
213
+ const message = err instanceof Error ? err.message : String(err);
214
+ this.sendResponse(id, false, undefined, `Cannot access directory: ${message}`);
215
+ break;
216
+ }
217
+ // ENOENT — directory doesn't exist yet, proceed with creation
218
+ }
219
+ // Create directory and git init
220
+ try {
221
+ await mkdir(folderPath, { recursive: true });
222
+ await promisify(execFile)('git', ['init'], { cwd: folderPath });
223
+ }
224
+ catch (err) {
225
+ const message = err instanceof Error ? err.message : String(err);
226
+ this.sendResponse(id, false, undefined, `Failed to create project: ${message}`);
227
+ break;
228
+ }
229
+ this.sendResponse(id, true, { folderPath });
135
230
  break;
136
231
  }
137
232
  case 'list_sessions': {
@@ -158,6 +253,7 @@ export class WsHandler {
158
253
  archived: archivedLookup.get(s.path) === true,
159
254
  isOwnedByMe: sl ? sl.connection?.connectedClientId === this.clientId : false,
160
255
  liveStatus: sl ? sl.sessionState.status : null,
256
+ cwd: s.cwd !== command.folderPath ? s.cwd : undefined,
161
257
  };
162
258
  })
163
259
  .filter((s) => command.includeArchived || !s.archived);
@@ -179,6 +275,7 @@ export class WsHandler {
179
275
  archived: false,
180
276
  isOwnedByMe: slot.connection?.connectedClientId === this.clientId,
181
277
  liveStatus: slot.sessionState.status,
278
+ cwd: slot.folderPath !== command.folderPath ? slot.folderPath : undefined,
182
279
  });
183
280
  }
184
281
  }
@@ -311,21 +408,23 @@ export class WsHandler {
311
408
  break;
312
409
  }
313
410
  case 'archive_session': {
314
- const archiveSessionId = command.sessionId;
411
+ const archiveSessionIds = command.sessionIds;
315
412
  const archiveFolderPath = command.folderPath;
316
- if (!archiveSessionId || !archiveFolderPath) {
317
- this.sendResponse(id, false, undefined, 'sessionId and folderPath are required');
413
+ if (!archiveSessionIds?.length || !archiveFolderPath) {
414
+ this.sendResponse(id, false, undefined, 'sessionIds and folderPath are required');
318
415
  break;
319
416
  }
320
- const archiveSlot = this.sessionManager.getSession(archiveSessionId);
321
- const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
322
- if (!archiveSessionPath) {
323
- this.sendResponse(id, false, undefined, `Session not found: ${archiveSessionId}`);
324
- break;
417
+ let archivedCount = 0;
418
+ for (const archiveSessionId of archiveSessionIds) {
419
+ const archiveSlot = this.sessionManager.getSession(archiveSessionId);
420
+ const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
421
+ if (!archiveSessionPath)
422
+ continue;
423
+ await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
424
+ this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
425
+ archivedCount++;
325
426
  }
326
- await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
327
- this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
328
- this.sendResponse(id, true, { archived: command.archived });
427
+ this.sendResponse(id, true, { archived: command.archived, count: archivedCount });
329
428
  break;
330
429
  }
331
430
  case 'rename_session': {
@@ -384,6 +483,65 @@ export class WsHandler {
384
483
  this.sendResponse(id, true, { sessionId: takeoverSessionId, killedProcesses: killedCount });
385
484
  break;
386
485
  }
486
+ // ---- Voice call control ----
487
+ case 'call_bind': {
488
+ if (!this.voiceOrchestrator) {
489
+ this.sendResponse(id, false, undefined, 'call_bind_failed_internal');
490
+ break;
491
+ }
492
+ const slot = this.sessionManager.getSlot(command.sessionId);
493
+ if (!slot) {
494
+ this.sendResponse(id, false, undefined, 'call_bind_failed_session_not_found');
495
+ break;
496
+ }
497
+ const connection = {
498
+ ws: this.ws,
499
+ connectedClientId: this.clientId,
500
+ onSessionReset: (s) => this.handleSessionReset(s),
501
+ };
502
+ try {
503
+ const data = await this.voiceOrchestrator.bindCall({
504
+ sessionId: command.sessionId,
505
+ clientConnection: connection,
506
+ force: command.force ?? false,
507
+ });
508
+ this.sendResponse(id, true, data);
509
+ this.sendEvent({ type: 'call_status', sessionId: command.sessionId, status: 'binding' });
510
+ }
511
+ catch (err) {
512
+ if (err instanceof CallBindError) {
513
+ this.sendResponse(id, false, undefined, err.code);
514
+ }
515
+ else {
516
+ console.warn('[voice] call_bind failed', err);
517
+ this.sendResponse(id, false, undefined, 'call_bind_failed_internal');
518
+ }
519
+ }
520
+ break;
521
+ }
522
+ case 'call_end': {
523
+ await this.voiceOrchestrator?.endCall({ sessionId: command.sessionId, reason: 'user_hangup' });
524
+ this.sendResponse(id, true);
525
+ this.sendEvent({ type: 'call_ended', sessionId: command.sessionId, reason: 'user_hangup' });
526
+ break;
527
+ }
528
+ // ---- Client diagnostic logs (voice/call tracing) ----
529
+ case 'client_log': {
530
+ // Forward to the server's logger so client-side traces interleave
531
+ // with the server-side voice extension logs in the same journal.
532
+ const clientWall = new Date(command.clientTimestampMs).toISOString();
533
+ const serverWall = new Date().toISOString();
534
+ const driftMs = Date.now() - command.clientTimestampMs;
535
+ const line = `[voice_trace][client/${command.tag}] ${command.message} ${JSON.stringify({ clientWall, serverWall, driftMs, ...(command.data ?? {}) })}`;
536
+ if (command.level === 'error')
537
+ console.error(line);
538
+ else if (command.level === 'warn')
539
+ console.warn(line);
540
+ else
541
+ console.log(line);
542
+ this.sendResponse(id, true);
543
+ break;
544
+ }
387
545
  // ---- Extension UI ----
388
546
  case 'extension_ui_response': {
389
547
  const uiSlot = command.sessionId ? this.sessionManager.getSession(command.sessionId) : undefined;
@@ -495,7 +653,10 @@ export class WsHandler {
495
653
  case 'get_commands':
496
654
  case 'complete_args':
497
655
  case 'set_session_name':
498
- case 'dequeue_steering': {
656
+ case 'dequeue_steering':
657
+ case 'fork':
658
+ case 'navigate_tree':
659
+ case 'set_tree_label': {
499
660
  await this.handleSessionCommand(command, id);
500
661
  break;
501
662
  }
@@ -538,6 +699,12 @@ export class WsHandler {
538
699
  this.sendResponse(id, true);
539
700
  break;
540
701
  }
702
+ if (trimmed === '/tree') {
703
+ const tree = mapTreeNodes(session.sessionManager.getTree());
704
+ const currentLeafId = session.sessionManager.getLeafId();
705
+ this.sendResponse(id, true, { tree, currentLeafId });
706
+ break;
707
+ }
541
708
  session.prompt(command.message, { images: parseDataUrlImages(command.images) }).catch((err) => {
542
709
  console.error(`[WsHandler] prompt error:`, err);
543
710
  });
@@ -566,7 +733,10 @@ export class WsHandler {
566
733
  case 'abort': {
567
734
  // Resolve pending UI responses first so stuck dialogs unblock
568
735
  resolveAllSlotPendingUi(slot);
569
- await session.abort();
736
+ const abortResult = await Promise.race([session.abort().then(() => 'ok'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000))]);
737
+ if (abortResult === 'timeout') {
738
+ console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
739
+ }
570
740
  this.sendResponse(id, true);
571
741
  break;
572
742
  }
@@ -630,6 +800,7 @@ export class WsHandler {
630
800
  const state = {
631
801
  model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
632
802
  thinkingLevel: session.thinkingLevel,
803
+ availableThinkingLevels: session.getAvailableThinkingLevels(),
633
804
  isStreaming: session.isStreaming,
634
805
  isCompacting: session.isCompacting,
635
806
  sessionFile: session.sessionFile,
@@ -643,6 +814,8 @@ export class WsHandler {
643
814
  }
644
815
  case 'get_messages': {
645
816
  const messages = mapAgentMessages(session.messages);
817
+ const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
818
+ applyEntryIds(messages, entryIds);
646
819
  this.sendResponse(id, true, { messages });
647
820
  break;
648
821
  }
@@ -696,7 +869,7 @@ export class WsHandler {
696
869
  });
697
870
  }
698
871
  // Pimote built-in commands
699
- commands.push({ name: 'new', description: 'Start a new session', hasArgCompletions: false }, { name: 'reload', description: 'Reload extensions and skills', hasArgCompletions: false });
872
+ commands.push({ name: 'new', description: 'Start a new session', hasArgCompletions: false }, { name: 'reload', description: 'Reload extensions and skills', hasArgCompletions: false }, { name: 'tree', description: 'Navigate session history tree', hasArgCompletions: false });
700
873
  this.sendResponse(id, true, { commands });
701
874
  break;
702
875
  }
@@ -720,16 +893,91 @@ export class WsHandler {
720
893
  this.sendResponse(id, true);
721
894
  break;
722
895
  }
896
+ case 'fork': {
897
+ if (!command.entryId) {
898
+ this.sendResponse(id, false, undefined, 'entryId is required');
899
+ break;
900
+ }
901
+ const forkResult = await slot.runtime.fork(command.entryId);
902
+ if (!forkResult.cancelled) {
903
+ await this.handleSessionReset(slot);
904
+ }
905
+ const forkData = { cancelled: forkResult.cancelled };
906
+ if (forkResult.selectedText !== undefined) {
907
+ forkData.selectedText = forkResult.selectedText;
908
+ }
909
+ this.sendResponse(id, true, forkData);
910
+ break;
911
+ }
912
+ case 'navigate_tree': {
913
+ if (slot.sessionState.treeNavigationInProgress) {
914
+ this.sendResponse(id, false, undefined, 'Tree navigation already in progress');
915
+ break;
916
+ }
917
+ const options = {};
918
+ if (command.summarize !== undefined)
919
+ options.summarize = command.summarize;
920
+ if (command.customInstructions !== undefined)
921
+ options.customInstructions = command.customInstructions;
922
+ if (command.replaceInstructions !== undefined)
923
+ options.replaceInstructions = command.replaceInstructions;
924
+ if (command.label !== undefined)
925
+ options.label = command.label;
926
+ slot.sessionState.treeNavigationInProgress = true;
927
+ this.emitBufferedSessionEvent(slot, sessionId, {
928
+ type: 'tree_navigation_start',
929
+ targetId: command.targetId,
930
+ summarizing: !!command.summarize,
931
+ });
932
+ let result;
933
+ try {
934
+ result = (await session.navigateTree(command.targetId, options));
935
+ }
936
+ finally {
937
+ slot.sessionState.treeNavigationInProgress = false;
938
+ this.emitBufferedSessionEvent(slot, sessionId, {
939
+ type: 'tree_navigation_end',
940
+ });
941
+ }
942
+ if (!result.cancelled) {
943
+ await this.handleSessionReset(slot);
944
+ }
945
+ const data = { cancelled: result.cancelled };
946
+ if (result.editorText !== undefined) {
947
+ data.editorText = result.editorText;
948
+ }
949
+ this.sendResponse(id, true, data);
950
+ break;
951
+ }
952
+ case 'set_tree_label': {
953
+ const normalizedLabel = command.label === '' ? undefined : command.label;
954
+ session.sessionManager.appendLabelChange(command.entryId, normalizedLabel);
955
+ this.sendResponse(id, true, { success: true });
956
+ break;
957
+ }
723
958
  }
724
959
  }
725
960
  /** Notify the old owner that they've been displaced from a session.
726
- * No-op if the session is unowned or owned by this client. */
961
+ * No-op if the session is unowned or owned by this client.
962
+ *
963
+ * Voice-call tear-down on displacement lives in `sendDisplacedEvent` (the
964
+ * old-owner-side site that also emits `call_ended { displaced }`), so this
965
+ * method does not call `voiceOrchestrator.endCall` itself — see review
966
+ * finding 4.
967
+ */
727
968
  displaceOwner(sessionId, slot) {
728
969
  if (slot.connection?.connectedClientId && slot.connection.connectedClientId !== this.clientId) {
729
970
  const oldHandler = this.clientRegistry.get(slot.connection.connectedClientId);
730
971
  if (oldHandler) {
731
972
  oldHandler.sendDisplacedEvent(sessionId);
732
973
  }
974
+ else if (this.voiceOrchestrator?.isCallActive(sessionId)) {
975
+ // Stale owner id with no live handler — clean up orchestrator state
976
+ // so the new owner doesn't inherit a phantom active call.
977
+ this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' }).catch((err) => {
978
+ console.warn('[voice] endCall on displace (stale handler) failed', err);
979
+ });
980
+ }
733
981
  }
734
982
  }
735
983
  /** Bind a slot to this client — sets ownership, WebSocket routing,
@@ -747,7 +995,9 @@ export class WsHandler {
747
995
  // ManagedSlot — on reconnect we skip rebinding, but on session reset
748
996
  // we must rebind so the bridge points at the new session state.
749
997
  if (!slot.sessionState.extensionsBound) {
750
- const uiContext = createExtensionUIBridge(slot, this.pushNotificationService);
998
+ const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
999
+ isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(sessionId) ?? false,
1000
+ });
751
1001
  const commandContextActions = createCommandContextActions(slot);
752
1002
  await slot.session.bindExtensions({ uiContext, commandContextActions });
753
1003
  slot.sessionState.extensionsBound = true;
@@ -766,9 +1016,12 @@ export class WsHandler {
766
1016
  return;
767
1017
  }
768
1018
  // Session ID changed — rebuild session state in-place on the same slot.
769
- const folderPath = slot.folderPath;
1019
+ // rebuildSessionState refreshes slot.folderPath from the new session's header cwd,
1020
+ // so capture folderPath AFTER the rebuild to pick up the new value (fork-from can
1021
+ // change cwd, e.g. the worktree extension).
770
1022
  // Rebuild session state (tears down old, creates new from runtime.session)
771
1023
  this.sessionManager.rebuildSessionState(slot);
1024
+ const folderPath = slot.folderPath;
772
1025
  // Re-key the session map
773
1026
  this.sessionManager.reKeySession(slot, oldSessionId, newSessionId);
774
1027
  // Update handler bookkeeping
@@ -778,7 +1031,9 @@ export class WsHandler {
778
1031
  this.viewedSessionId = newSessionId;
779
1032
  }
780
1033
  // Rebind extension UI bridge (new session state for dialog routing)
781
- const uiContext = createExtensionUIBridge(slot, this.pushNotificationService);
1034
+ const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1035
+ isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newSessionId) ?? false,
1036
+ });
782
1037
  const commandContextActions = createCommandContextActions(slot);
783
1038
  await slot.session.bindExtensions({ uiContext, commandContextActions });
784
1039
  slot.sessionState.extensionsBound = true;
@@ -891,6 +1146,27 @@ export class WsHandler {
891
1146
  sessionId,
892
1147
  reason: 'displaced',
893
1148
  });
1149
+ // If the old owner had an active voice call on this session, tear down
1150
+ // orchestrator bookkeeping and surface `call_ended { reason: 'displaced' }`
1151
+ // so their VoiceCallStore tears down alongside the session_closed.
1152
+ if (this.voiceOrchestrator?.isCallActive(sessionId)) {
1153
+ void this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' });
1154
+ this.sendEvent({
1155
+ type: 'call_ended',
1156
+ sessionId,
1157
+ reason: 'displaced',
1158
+ });
1159
+ }
1160
+ }
1161
+ /** Broadcast a `call_ended` to this client (used by the session manager's
1162
+ * before-close hook so the orchestrator bookkeeping owner learns that a
1163
+ * server-initiated teardown happened). */
1164
+ sendCallEndedEvent(sessionId, reason) {
1165
+ this.sendEvent({
1166
+ type: 'call_ended',
1167
+ sessionId,
1168
+ reason,
1169
+ });
894
1170
  }
895
1171
  /** Send a session_closed event with reason 'killed' to this client's WebSocket.
896
1172
  * Also removes the session from this handler's subscribedSessions so that
@@ -935,6 +1211,30 @@ export class WsHandler {
935
1211
  console.error('[WsHandler] Failed to send event:', err);
936
1212
  }
937
1213
  }
1214
+ emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
1215
+ let forwarded = false;
1216
+ slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
1217
+ // Augment agent_end with message entry IDs so the client can enable
1218
+ // fork targets on messages that arrived via streaming (without IDs).
1219
+ if (event.type === 'agent_end') {
1220
+ const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
1221
+ event.messageEntryIds = entryIds;
1222
+ }
1223
+ forwarded = true;
1224
+ this.sendEvent(event);
1225
+ }, () => slot.session.messages[slot.session.messages.length - 1]);
1226
+ // Test doubles for EventBuffer may no-op on onEvent().
1227
+ // Fallback keeps lifecycle visibility in tests while real runtime uses buffered forwarding above.
1228
+ if (!forwarded) {
1229
+ const cursorBase = slot.sessionState.eventBuffer.currentCursor;
1230
+ const cursor = typeof cursorBase === 'number' ? cursorBase + 1 : 1;
1231
+ this.sendEvent({
1232
+ ...sdkEvent,
1233
+ sessionId,
1234
+ cursor,
1235
+ });
1236
+ }
1237
+ }
938
1238
  /** Send a full_resync event to the client for the given managed session.
939
1239
  * Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
940
1240
  sendFullResyncForSession(pimoteSessionId, slot) {
@@ -943,6 +1243,7 @@ export class WsHandler {
943
1243
  const state = {
944
1244
  model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
945
1245
  thinkingLevel: session.thinkingLevel,
1246
+ availableThinkingLevels: session.getAvailableThinkingLevels(),
946
1247
  isStreaming: session.isStreaming,
947
1248
  isCompacting: session.isCompacting,
948
1249
  sessionFile: session.sessionFile,
@@ -952,6 +1253,8 @@ export class WsHandler {
952
1253
  messageCount: session.messages.length,
953
1254
  };
954
1255
  const messages = mapAgentMessages(session.messages);
1256
+ const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
1257
+ applyEntryIds(messages, entryIds);
955
1258
  const fullResyncEvent = {
956
1259
  type: 'full_resync',
957
1260
  sessionId: pimoteSessionId,
@@ -1007,6 +1310,7 @@ export class WsHandler {
1007
1310
  sessionName,
1008
1311
  firstMessage,
1009
1312
  messageCount,
1313
+ gitBranch: slot ? getGitBranch(slot.folderPath) : null,
1010
1314
  };
1011
1315
  for (const [, handler] of clientRegistry) {
1012
1316
  handler.sendToClient(event);
@@ -0,0 +1 @@
1
+ export * from './protocol.js';
@@ -0,0 +1,2 @@
1
+ // @pimote/shared barrel export
2
+ export * from './protocol.js';