@pimote/pimote 0.6.0 → 0.8.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 (51) hide show
  1. package/README.md +4 -1
  2. package/client/build/_app/immutable/assets/0.DmHGeVyH.css +2 -0
  3. package/client/build/_app/immutable/assets/{2.bfMycywk.css → 2.BtlPyuHL.css} +1 -1
  4. package/client/build/_app/immutable/chunks/BjlKVpoO.js +1 -0
  5. package/client/build/_app/immutable/chunks/Blm_TLGW.js +1 -0
  6. package/client/build/_app/immutable/chunks/COcpV1OD.js +1 -0
  7. package/client/build/_app/immutable/chunks/DMWd5mk8.js +1 -0
  8. package/client/build/_app/immutable/chunks/{DNqQZw5U.js → Daen0SYI.js} +2 -2
  9. package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.DW3BNxC_.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.DUfrZpFg.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DzBRsuZ_.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.y-VB1JIj.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.Bz9KycIe.js +55 -0
  14. package/client/build/_app/version.json +1 -1
  15. package/client/build/index.html +7 -7
  16. package/package.json +2 -2
  17. package/server/dist/config.js +5 -2
  18. package/server/dist/event-buffer.js +9 -0
  19. package/server/dist/extension-ui-bridge.js +26 -10
  20. package/server/dist/file-references.js +123 -0
  21. package/server/dist/git-branch.js +12 -9
  22. package/server/dist/login-orchestrator.js +114 -0
  23. package/server/dist/push-infrastructure.js +13 -2
  24. package/server/dist/push-notification.js +18 -11
  25. package/server/dist/server.js +25 -2
  26. package/server/dist/session-cost.js +26 -2
  27. package/server/dist/session-manager.js +109 -6
  28. package/server/dist/static-host/gc.js +13 -0
  29. package/server/dist/static-host/http-handler.js +27 -1
  30. package/server/dist/static-host/index.js +24 -12
  31. package/server/dist/static-host/store.js +10 -1
  32. package/server/dist/static-host/tools.js +5 -1
  33. package/server/dist/voice/fsm/reducer.js +14 -2
  34. package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
  35. package/server/dist/voice/fsm/reducers/streaming.js +39 -3
  36. package/server/dist/voice/fsm/reducers/walkback.js +13 -10
  37. package/server/dist/voice/fsm/state.js +1 -1
  38. package/server/dist/voice/index.js +97 -41
  39. package/server/dist/voice/walk-back.js +94 -26
  40. package/server/dist/voice-orchestrator-boot.js +22 -5
  41. package/server/dist/voice-orchestrator.js +38 -1
  42. package/server/dist/ws-handler.js +195 -63
  43. package/shared/dist/protocol.d.ts +99 -2
  44. package/client/build/_app/immutable/assets/0.Dh2gYJ1J.css +0 -2
  45. package/client/build/_app/immutable/chunks/Czpnrh9t.js +0 -1
  46. package/client/build/_app/immutable/chunks/D1mCuOEu.js +0 -1
  47. package/client/build/_app/immutable/chunks/DHiuV2ft.js +0 -1
  48. package/client/build/_app/immutable/chunks/DegHYiTr.js +0 -1
  49. package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
  50. package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
  51. package/client/build/_app/immutable/nodes/2.CZjPJM-S.js +0 -55
@@ -3,12 +3,13 @@ import { execFile } from 'node:child_process';
3
3
  import { join, sep } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { resolveAllSlotPendingUi, resolveSlotPendingUi, replaySlotPendingUiRequests } from './session-manager.js';
6
+ import { LoginBusyError } from './login-orchestrator.js';
6
7
  import { getMergedPanelCards } from './panel-state.js';
7
8
  import { createExtensionUIBridge } from './extension-ui-bridge.js';
8
9
  import { findExternalPiProcesses, killExternalPiProcesses } from './takeover.js';
9
10
  import { mapAgentMessages, extractMessageEntryIds, applyEntryIds } from './message-mapper.js';
10
- import { getGitBranch } from './git-branch.js';
11
11
  import { sumAssistantCostUsd } from './session-cost.js';
12
+ import { completeFileRefs } from './file-references.js';
12
13
  import { CallBindError } from './voice-orchestrator.js';
13
14
  /** Parse data-URL encoded images into the shape the pi SDK expects. */
14
15
  function parseDataUrlImages(images) {
@@ -84,10 +85,11 @@ export function mapTreeNodes(nodes) {
84
85
  /**
85
86
  * Create command context actions for extension commands.
86
87
  * Captures the ManagedSlot (stable lifetime), not a transient handler.
87
- * Session resets are routed through slot.connection.onSessionReset which the
88
- * current handler sets on claim and clears on cleanup.
88
+ * Session resets funnel through sessionManager.applySessionReset, which always
89
+ * reconciles the session map and then notifies the slot's current owner (if any)
90
+ * via slot.connection.onSessionReset — so a reset with no live owner still re-keys.
89
91
  */
90
- function createCommandContextActions(slot) {
92
+ function createCommandContextActions(slot, sessionManager) {
91
93
  return {
92
94
  waitForIdle: () => {
93
95
  if (!slot.session.isStreaming)
@@ -107,25 +109,25 @@ function createCommandContextActions(slot) {
107
109
  newSession: async (options) => {
108
110
  const result = await slot.runtime.newSession(options);
109
111
  if (!result.cancelled)
110
- await slot.connection?.onSessionReset?.(slot);
112
+ await sessionManager.applySessionReset(slot);
111
113
  return { cancelled: result.cancelled };
112
114
  },
113
115
  fork: async (entryId) => {
114
116
  const result = await slot.runtime.fork(entryId);
115
117
  if (!result.cancelled)
116
- await slot.connection?.onSessionReset?.(slot);
118
+ await sessionManager.applySessionReset(slot);
117
119
  return { cancelled: result.cancelled };
118
120
  },
119
121
  navigateTree: async (targetId, options) => {
120
122
  const result = await slot.session.navigateTree(targetId, options);
121
123
  if (!result.cancelled)
122
- await slot.connection?.onSessionReset?.(slot);
124
+ await sessionManager.applySessionReset(slot);
123
125
  return { cancelled: result.cancelled };
124
126
  },
125
127
  switchSession: async (sessionPath) => {
126
128
  const result = await slot.runtime.switchSession(sessionPath);
127
129
  if (!result.cancelled)
128
- await slot.connection?.onSessionReset?.(slot);
130
+ await sessionManager.applySessionReset(slot);
129
131
  return { cancelled: result.cancelled };
130
132
  },
131
133
  reload: () => slot.session.reload(),
@@ -140,7 +142,12 @@ export class WsHandler {
140
142
  clientRegistry;
141
143
  voiceOrchestrator;
142
144
  subscribedSessions = new Set();
145
+ fdWarningEmitted = false;
143
146
  viewedSessionId = null;
147
+ /** Pending login prompt/select inputs keyed by requestId (mirrors pendingUiResponses). */
148
+ pendingLoginInputs = new Map();
149
+ /** AbortController for the in-flight login flow this connection initiated, if any. */
150
+ loginAbort = null;
144
151
  clientId;
145
152
  constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator) {
146
153
  this.sessionManager = sessionManager;
@@ -164,6 +171,12 @@ export class WsHandler {
164
171
  this.sendResponse('unknown', false, undefined, 'Invalid JSON');
165
172
  return;
166
173
  }
174
+ // Guard against non-object payloads (e.g. `null`, a bare number/string):
175
+ // dereferencing `command.id` on those throws outside the response try block.
176
+ if (command === null || typeof command !== 'object') {
177
+ this.sendResponse('unknown', false, undefined, 'Invalid command');
178
+ return;
179
+ }
167
180
  const id = command.id ?? 'unknown';
168
181
  try {
169
182
  switch (command.type) {
@@ -501,7 +514,7 @@ export class WsHandler {
501
514
  const connection = {
502
515
  ws: this.ws,
503
516
  connectedClientId: this.clientId,
504
- onSessionReset: (s) => this.handleSessionReset(s),
517
+ onSessionReset: (s, outcome) => this.handleSessionReset(s, outcome),
505
518
  };
506
519
  try {
507
520
  const data = await this.voiceOrchestrator.bindCall({
@@ -524,9 +537,15 @@ export class WsHandler {
524
537
  break;
525
538
  }
526
539
  case 'call_end': {
540
+ // Route call_ended to the call's OWNER, not necessarily the requester:
541
+ // a non-owner ending the call must still tear down the owner's
542
+ // VoiceCallStore (otherwise it stays `active` until WebRTC dies). In the
543
+ // normal flow requester === owner, so this targets the same socket.
544
+ const ownerClientId = this.sessionManager.getSlot(command.sessionId)?.connection?.connectedClientId;
527
545
  await this.voiceOrchestrator?.endCall({ sessionId: command.sessionId, reason: 'user_hangup' });
528
546
  this.sendResponse(id, true);
529
- this.sendEvent({ type: 'call_ended', sessionId: command.sessionId, reason: 'user_hangup' });
547
+ const ownerHandler = ownerClientId ? this.clientRegistry.get(ownerClientId) : undefined;
548
+ (ownerHandler ?? this).sendCallEndedEvent(command.sessionId, 'user_hangup');
530
549
  break;
531
550
  }
532
551
  // ---- Client diagnostic logs (voice/call tracing) ----
@@ -656,6 +675,7 @@ export class WsHandler {
656
675
  case 'get_session_meta':
657
676
  case 'get_commands':
658
677
  case 'complete_args':
678
+ case 'complete_file_refs':
659
679
  case 'set_session_name':
660
680
  case 'dequeue_steering':
661
681
  case 'fork':
@@ -664,6 +684,62 @@ export class WsHandler {
664
684
  await this.handleSessionCommand(command, id);
665
685
  break;
666
686
  }
687
+ // -- Global login commands (NOT session-scoped) --
688
+ case 'login_list': {
689
+ const providers = this.sessionManager.getLoginOrchestrator().listProviders();
690
+ this.sendResponse(id, true, { providers });
691
+ break;
692
+ }
693
+ case 'login_begin': {
694
+ const orchestrator = this.sessionManager.getLoginOrchestrator();
695
+ if (orchestrator.isBusy()) {
696
+ this.sendResponse(id, true, { ok: false, reason: 'busy' });
697
+ break;
698
+ }
699
+ const transport = this.createLoginTransport();
700
+ const flowController = this.loginAbort;
701
+ // Drive the flow async — it emits login_step events as it goes and a
702
+ // terminal `done` step on completion. Respond `ok` immediately.
703
+ orchestrator
704
+ .runLogin(command.providerId, transport)
705
+ .catch((err) => {
706
+ if (err instanceof LoginBusyError)
707
+ return;
708
+ console.error('[WsHandler] login flow error:', err);
709
+ })
710
+ .finally(() => {
711
+ // Settle any inputs still outstanding when the flow ends for a
712
+ // reason other than a client cancel (provider-side timeout,
713
+ // network error during the manual-input race) so the promises pi
714
+ // awaited don't dangle, and clear the now-completed controller.
715
+ this.settlePendingLoginInputs();
716
+ if (this.loginAbort === flowController) {
717
+ this.loginAbort = null;
718
+ }
719
+ });
720
+ this.sendResponse(id, true, { ok: true });
721
+ break;
722
+ }
723
+ case 'login_input': {
724
+ const pending = this.pendingLoginInputs.get(command.requestId);
725
+ if (pending) {
726
+ this.pendingLoginInputs.delete(command.requestId);
727
+ pending.resolve(command.value);
728
+ }
729
+ this.sendResponse(id, true);
730
+ break;
731
+ }
732
+ case 'login_cancel': {
733
+ this.loginAbort?.abort();
734
+ this.settlePendingLoginInputs('login cancelled');
735
+ this.sendResponse(id, true);
736
+ break;
737
+ }
738
+ case 'logout': {
739
+ this.sessionManager.getLoginOrchestrator().logout(command.providerId);
740
+ this.sendResponse(id, true, { ok: true });
741
+ break;
742
+ }
667
743
  default: {
668
744
  this.sendResponse(id, false, undefined, `Unknown command type: ${command.type}`);
669
745
  }
@@ -694,7 +770,7 @@ export class WsHandler {
694
770
  if (trimmed === '/new') {
695
771
  const result = await slot.runtime.newSession();
696
772
  if (!result.cancelled)
697
- await slot.connection?.onSessionReset?.(slot);
773
+ await this.sessionManager.applySessionReset(slot);
698
774
  this.sendResponse(id, true, { success: !result.cancelled });
699
775
  break;
700
776
  }
@@ -737,7 +813,15 @@ export class WsHandler {
737
813
  case 'abort': {
738
814
  // Resolve pending UI responses first so stuck dialogs unblock
739
815
  resolveAllSlotPendingUi(slot);
740
- const abortResult = await Promise.race([session.abort().then(() => 'ok'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000))]);
816
+ let abortWatchdog;
817
+ const abortResult = await Promise.race([
818
+ session.abort().then(() => 'ok'),
819
+ new Promise((resolve) => {
820
+ abortWatchdog = setTimeout(() => resolve('timeout'), 30_000);
821
+ }),
822
+ ]);
823
+ if (abortWatchdog !== undefined)
824
+ clearTimeout(abortWatchdog);
741
825
  if (abortResult === 'timeout') {
742
826
  console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
743
827
  }
@@ -826,7 +910,7 @@ export class WsHandler {
826
910
  case 'new_session': {
827
911
  const result = await slot.runtime.newSession();
828
912
  if (!result.cancelled)
829
- await slot.connection?.onSessionReset?.(slot);
913
+ await this.sessionManager.applySessionReset(slot);
830
914
  this.sendResponse(id, true, { success: !result.cancelled });
831
915
  break;
832
916
  }
@@ -838,9 +922,14 @@ export class WsHandler {
838
922
  case 'get_session_meta': {
839
923
  const contextUsage = session.getContextUsage();
840
924
  const meta = {
841
- gitBranch: getGitBranch(slot.folderPath),
925
+ gitBranch: this.sessionManager.getLastKnownGitBranch(sessionId),
842
926
  contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
843
- lifetimeCostUsd: sumAssistantCostUsd(session.sessionManager.getBranch()),
927
+ // getEntries() (not getBranch()) so cost spans ALL branches in the
928
+ // session file, not just the current leaf's branch. Survives live
929
+ // switches and reload-from-disk because it is recomputed from the
930
+ // session manager's rehydrated entries every call. See session-cost.ts
931
+ // for what this figure excludes.
932
+ lifetimeCostUsd: sumAssistantCostUsd(session.sessionManager.getEntries()),
844
933
  };
845
934
  this.sendResponse(id, true, { meta });
846
935
  break;
@@ -874,7 +963,7 @@ export class WsHandler {
874
963
  });
875
964
  }
876
965
  // Pimote built-in commands
877
- 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 });
966
+ 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 }, { name: 'login', description: 'Log in to a model provider', hasArgCompletions: false }, { name: 'logout', description: 'Log out from a model provider', hasArgCompletions: false });
878
967
  this.sendResponse(id, true, { commands });
879
968
  break;
880
969
  }
@@ -893,6 +982,14 @@ export class WsHandler {
893
982
  this.sendResponse(id, true, { items: items ?? null });
894
983
  break;
895
984
  }
985
+ case 'complete_file_refs': {
986
+ const result = await completeFileRefs({ prefix: command.prefix, cwd: slot.folderPath });
987
+ if (!result.fdAvailable) {
988
+ this.emitFdMissingWarning(sessionId);
989
+ }
990
+ this.sendResponse(id, true, { items: result.items });
991
+ break;
992
+ }
896
993
  case 'set_session_name': {
897
994
  session.setSessionName(command.name);
898
995
  this.sendResponse(id, true);
@@ -905,7 +1002,7 @@ export class WsHandler {
905
1002
  }
906
1003
  const forkResult = await slot.runtime.fork(command.entryId);
907
1004
  if (!forkResult.cancelled) {
908
- await this.handleSessionReset(slot);
1005
+ await this.sessionManager.applySessionReset(slot);
909
1006
  }
910
1007
  const forkData = { cancelled: forkResult.cancelled };
911
1008
  if (forkResult.selectedText !== undefined) {
@@ -945,7 +1042,7 @@ export class WsHandler {
945
1042
  });
946
1043
  }
947
1044
  if (!result.cancelled) {
948
- await this.handleSessionReset(slot);
1045
+ await this.sessionManager.applySessionReset(slot);
949
1046
  }
950
1047
  const data = { cancelled: result.cancelled };
951
1048
  if (result.editorText !== undefined) {
@@ -986,12 +1083,14 @@ export class WsHandler {
986
1083
  }
987
1084
  }
988
1085
  /** Bind a slot to this client — sets ownership, WebSocket routing,
989
- * and subscribes to events. Extensions are bound once on first claim. */
1086
+ * and subscribes to events. Extensions are bound once on first claim. Public
1087
+ * so the voice force-bind path (displaceOwner wiring) can transfer ownership
1088
+ * through this same single operation, exactly like open_session does. */
990
1089
  async claimSession(sessionId, slot) {
991
1090
  const connection = {
992
1091
  ws: this.ws,
993
1092
  connectedClientId: this.clientId,
994
- onSessionReset: (s) => this.handleSessionReset(s),
1093
+ onSessionReset: (s, outcome) => this.handleSessionReset(s, outcome),
995
1094
  };
996
1095
  slot.connection = connection;
997
1096
  // Note: do NOT touch `idleSince` here. Idleness is an agent-level concept driven by
@@ -1004,50 +1103,42 @@ export class WsHandler {
1004
1103
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1005
1104
  isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(sessionId) ?? false,
1006
1105
  });
1007
- const commandContextActions = createCommandContextActions(slot);
1106
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1008
1107
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1009
1108
  slot.sessionState.extensionsBound = true;
1010
1109
  }
1011
1110
  // Re-deliver any pending UI requests to the new client (recovers lost dialogs)
1012
1111
  replaySlotPendingUiRequests(slot);
1013
1112
  }
1014
- /** Handle a session reset (newSession, fork, switchSession).
1015
- * Called via slot.connection.onSessionReset after the runtime has replaced the session. */
1016
- async handleSessionReset(slot) {
1017
- const newSessionId = slot.runtime.session.sessionId;
1018
- const oldSessionId = slot.sessionState.id;
1019
- // navigateTree stays in the same file — same session ID, just resync
1020
- if (newSessionId === oldSessionId) {
1021
- this.sendFullResyncForSession(oldSessionId, slot);
1113
+ /** Notify-only reaction to a session reset that the session manager has ALREADY
1114
+ * reconciled in the map (rebuild + collision-evict + re-key). Installed as the
1115
+ * owning connection's onSessionReset; runs only for the slot's current owner.
1116
+ * Does no map mutation itself — see SessionManager.applySessionReset. */
1117
+ async handleSessionReset(slot, outcome) {
1118
+ // navigateTree stays in the same file — same session ID, just resync.
1119
+ if (outcome.kind === 'unchanged') {
1120
+ this.sendFullResyncForSession(slot.sessionState.id, slot);
1022
1121
  return;
1023
1122
  }
1024
- // Session ID changed rebuild session state in-place on the same slot.
1025
- // rebuildSessionState refreshes slot.folderPath from the new session's header cwd,
1026
- // so capture folderPath AFTER the rebuild to pick up the new value (fork-from can
1027
- // change cwd, e.g. the worktree extension).
1028
- // Rebuild session state (tears down old, creates new from runtime.session)
1029
- this.sessionManager.rebuildSessionState(slot);
1030
- const folderPath = slot.folderPath;
1031
- // Re-key the session map
1032
- this.sessionManager.reKeySession(slot, oldSessionId, newSessionId);
1123
+ const { oldId, newId, folderPath } = outcome;
1033
1124
  // Update handler bookkeeping
1034
- this.subscribedSessions.delete(oldSessionId);
1035
- this.subscribedSessions.add(newSessionId);
1036
- if (this.viewedSessionId === oldSessionId) {
1037
- this.viewedSessionId = newSessionId;
1125
+ this.subscribedSessions.delete(oldId);
1126
+ this.subscribedSessions.add(newId);
1127
+ if (this.viewedSessionId === oldId) {
1128
+ this.viewedSessionId = newId;
1038
1129
  }
1039
1130
  // Rebind extension UI bridge (new session state for dialog routing)
1040
1131
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1041
- isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newSessionId) ?? false,
1132
+ isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newId) ?? false,
1042
1133
  });
1043
- const commandContextActions = createCommandContextActions(slot);
1134
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1044
1135
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1045
1136
  slot.sessionState.extensionsBound = true;
1046
1137
  // Notify owning client: session replaced (client re-keys in place)
1047
1138
  this.sendEvent({
1048
1139
  type: 'session_replaced',
1049
- oldSessionId,
1050
- newSessionId,
1140
+ oldSessionId: oldId,
1141
+ newSessionId: newId,
1051
1142
  folder: {
1052
1143
  path: folderPath,
1053
1144
  name: folderPath.split('/').pop() ?? folderPath,
@@ -1057,8 +1148,22 @@ export class WsHandler {
1057
1148
  },
1058
1149
  });
1059
1150
  // Broadcast sidebar updates for both old (now inactive) and new (now active)
1060
- WsHandler.broadcastSidebarUpdate(oldSessionId, folderPath, this.sessionManager, this.clientRegistry);
1061
- WsHandler.broadcastSidebarUpdate(newSessionId, folderPath, this.sessionManager, this.clientRegistry);
1151
+ WsHandler.broadcastSidebarUpdate(oldId, folderPath, this.sessionManager, this.clientRegistry);
1152
+ WsHandler.broadcastSidebarUpdate(newId, folderPath, this.sessionManager, this.clientRegistry);
1153
+ }
1154
+ /** Surface the fd-missing warning at most once per connection (fire-and-forget toast). */
1155
+ emitFdMissingWarning(sessionId) {
1156
+ if (this.fdWarningEmitted)
1157
+ return;
1158
+ this.fdWarningEmitted = true;
1159
+ this.sendEvent({
1160
+ type: 'extension_ui_request',
1161
+ sessionId,
1162
+ requestId: `fd-missing-${Date.now()}`,
1163
+ method: 'notify',
1164
+ message: 'fd not found — file autocomplete is unavailable. Install fd to enable it.',
1165
+ notifyType: 'warning',
1166
+ });
1062
1167
  }
1063
1168
  buildFolderInfo(folderPath) {
1064
1169
  return {
@@ -1156,7 +1261,9 @@ export class WsHandler {
1156
1261
  // orchestrator bookkeeping and surface `call_ended { reason: 'displaced' }`
1157
1262
  // so their VoiceCallStore tears down alongside the session_closed.
1158
1263
  if (this.voiceOrchestrator?.isCallActive(sessionId)) {
1159
- void this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' });
1264
+ this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' }).catch((err) => {
1265
+ console.warn('[voice] endCall on displace failed', err);
1266
+ });
1160
1267
  this.sendEvent({
1161
1268
  type: 'call_ended',
1162
1269
  sessionId,
@@ -1217,8 +1324,39 @@ export class WsHandler {
1217
1324
  console.error('[WsHandler] Failed to send event:', err);
1218
1325
  }
1219
1326
  }
1327
+ /** Reject + clear any outstanding login prompt/select inputs for this
1328
+ * connection. pi attaches a `.catch` to the manual-code promise, so rejecting
1329
+ * is safe and won't surface an unhandled rejection. */
1330
+ settlePendingLoginInputs(reason = 'login flow ended') {
1331
+ for (const [, pending] of this.pendingLoginInputs) {
1332
+ pending.reject(new Error(reason));
1333
+ }
1334
+ this.pendingLoginInputs.clear();
1335
+ }
1336
+ /** Build a connection-bound LoginTransport: events flow to this client, and
1337
+ * prompt/select inputs resolve via this connection's pendingLoginInputs map. */
1338
+ createLoginTransport() {
1339
+ const controller = new AbortController();
1340
+ this.loginAbort = controller;
1341
+ const awaitInput = (requestId) => new Promise((resolve, reject) => {
1342
+ this.pendingLoginInputs.set(requestId, { resolve, reject });
1343
+ });
1344
+ return {
1345
+ emit: (step) => {
1346
+ this.sendEvent({ type: 'login_step', step });
1347
+ },
1348
+ requestInput: ({ requestId, message, placeholder, allowEmpty }) => {
1349
+ this.sendEvent({ type: 'login_step', step: { kind: 'prompt', requestId, message, placeholder, allowEmpty } });
1350
+ return awaitInput(requestId);
1351
+ },
1352
+ requestSelect: ({ requestId, message, options }) => {
1353
+ this.sendEvent({ type: 'login_step', step: { kind: 'select', requestId, message, options } });
1354
+ return awaitInput(requestId);
1355
+ },
1356
+ signal: controller.signal,
1357
+ };
1358
+ }
1220
1359
  emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
1221
- let forwarded = false;
1222
1360
  slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
1223
1361
  // Augment agent_end with message entry IDs so the client can enable
1224
1362
  // fork targets on messages that arrived via streaming (without IDs).
@@ -1226,20 +1364,8 @@ export class WsHandler {
1226
1364
  const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
1227
1365
  event.messageEntryIds = entryIds;
1228
1366
  }
1229
- forwarded = true;
1230
1367
  this.sendEvent(event);
1231
1368
  }, () => slot.session.messages[slot.session.messages.length - 1]);
1232
- // Test doubles for EventBuffer may no-op on onEvent().
1233
- // Fallback keeps lifecycle visibility in tests while real runtime uses buffered forwarding above.
1234
- if (!forwarded) {
1235
- const cursorBase = slot.sessionState.eventBuffer.currentCursor;
1236
- const cursor = typeof cursorBase === 'number' ? cursorBase + 1 : 1;
1237
- this.sendEvent({
1238
- ...sdkEvent,
1239
- sessionId,
1240
- cursor,
1241
- });
1242
- }
1243
1369
  }
1244
1370
  /** Send a full_resync event to the client for the given managed session.
1245
1371
  * Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
@@ -1316,13 +1442,19 @@ export class WsHandler {
1316
1442
  sessionName,
1317
1443
  firstMessage,
1318
1444
  messageCount,
1319
- gitBranch: slot ? getGitBranch(slot.folderPath) : null,
1445
+ gitBranch: slot ? sessionManager.getLastKnownGitBranch(sessionId) : null,
1320
1446
  };
1321
1447
  for (const [, handler] of clientRegistry) {
1322
1448
  handler.sendToClient(event);
1323
1449
  }
1324
1450
  }
1325
1451
  cleanup() {
1452
+ // If this connection had a login flow in flight, abort it and settle any
1453
+ // dangling input promises (mirror of `login_cancel`). The LoginOrchestrator
1454
+ // is single-flight and server-wide, so leaving it busy would wedge logins
1455
+ // for every client until restart.
1456
+ this.loginAbort?.abort();
1457
+ this.settlePendingLoginInputs('connection closed');
1326
1458
  for (const sid of this.subscribedSessions) {
1327
1459
  const slot = this.sessionManager.getSession(sid);
1328
1460
  if (slot) {
@@ -212,6 +212,19 @@ export interface CompleteArgsCommand extends CommandBase {
212
212
  commandName: string;
213
213
  prefix: string;
214
214
  }
215
+ /**
216
+ * `@`-file-path autocomplete request (client → server). Mirrors pi's interactive
217
+ * TUI `@` behavior: the client extracts the `@`-token immediately before the
218
+ * cursor (including `@"…"` quoted tokens) and asks the server for `fd`-backed
219
+ * path suggestions resolved against the session's working directory. The
220
+ * response reuses the generic `AutocompleteResponseItem` shape. This is
221
+ * autocomplete-only — there is no server-side `@`-reference expansion.
222
+ */
223
+ export interface CompleteFileRefsCommand extends CommandBase {
224
+ type: 'complete_file_refs';
225
+ /** The client-extracted `@`-token as typed, including the leading `@` (and opening quote if quoted), e.g. `@src/`, `@"my dir/`. */
226
+ prefix: string;
227
+ }
215
228
  export interface CommandInfo {
216
229
  name: string;
217
230
  description: string;
@@ -344,6 +357,47 @@ export interface CallEndCommand extends CommandBase {
344
357
  export type CallBindErrorCode = 'call_bind_failed_session_not_found' | 'call_bind_failed_owned' | 'call_bind_failed_internal';
345
358
  /** Reason code returned when an extension attempts a UI bridge call during a voice call. */
346
359
  export declare const UI_BRIDGE_DISABLED_IN_VOICE_MODE = "ui_bridge_disabled_in_voice_mode";
360
+ /** A single OAuth provider's logged-in status, for the login picker. */
361
+ export interface LoginProviderInfo {
362
+ id: string;
363
+ name: string;
364
+ loggedIn: boolean;
365
+ }
366
+ /** List OAuth providers and their logged-in status. resp: LoginListResponseData */
367
+ export interface LoginListCommand extends CommandBase {
368
+ type: 'login_list';
369
+ }
370
+ /** Begin an interactive login flow for a provider. resp: LoginBeginResponseData */
371
+ export interface LoginBeginCommand extends CommandBase {
372
+ type: 'login_begin';
373
+ providerId: string;
374
+ }
375
+ /** Resolve a pending login prompt/select keyed by `requestId`. */
376
+ export interface LoginInputCommand extends CommandBase {
377
+ type: 'login_input';
378
+ requestId: string;
379
+ value: string;
380
+ }
381
+ /** Abort the in-flight login flow (fires the AbortSignal, rejects pending input). */
382
+ export interface LoginCancelCommand extends CommandBase {
383
+ type: 'login_cancel';
384
+ }
385
+ /** Log out from a provider (clears its stored credential). resp: LogoutResponseData */
386
+ export interface LogoutCommand extends CommandBase {
387
+ type: 'logout';
388
+ providerId: string;
389
+ }
390
+ export interface LoginListResponseData {
391
+ providers: LoginProviderInfo[];
392
+ }
393
+ export interface LoginBeginResponseData {
394
+ ok: boolean;
395
+ /** Present when ok === false because a login flow is already in progress. */
396
+ reason?: 'busy';
397
+ }
398
+ export interface LogoutResponseData {
399
+ ok: boolean;
400
+ }
347
401
  export interface ExtensionUiResponseCommand extends CommandBase {
348
402
  type: 'extension_ui_response';
349
403
  requestId: string;
@@ -351,7 +405,7 @@ export interface ExtensionUiResponseCommand extends CommandBase {
351
405
  confirmed?: boolean;
352
406
  cancelled?: boolean;
353
407
  }
354
- export type PimoteCommand = PromptCommand | SteerCommand | FollowUpCommand | AbortCommand | ClientLogCommand | SetModelCommand | CycleModelCommand | GetAvailableModelsCommand | SetThinkingLevelCommand | CycleThinkingLevelCommand | CompactCommand | SetAutoCompactionCommand | GetStateCommand | GetMessagesCommand | NewSessionCommand | GetSessionStatsCommand | GetSessionMetaCommand | GetCommandsCommand | CompleteArgsCommand | SetSessionNameCommand | DequeueSteeringCommand | ForkCommand | NavigateTreeCommand | SetTreeLabelCommand | CreateProjectCommand | RenameSessionCommand | ListFoldersCommand | ListSessionsCommand | OpenSessionCommand | CloseSessionCommand | DeleteSessionCommand | ArchiveSessionCommand | TakeoverFolderCommand | ViewSessionCommand | RegisterPushCommand | UnregisterPushCommand | KillConflictingProcessesCommand | KillConflictingSessionsCommand | ExtensionUiResponseCommand | CallBindCommand | CallEndCommand;
408
+ export type PimoteCommand = PromptCommand | SteerCommand | FollowUpCommand | AbortCommand | ClientLogCommand | SetModelCommand | CycleModelCommand | GetAvailableModelsCommand | SetThinkingLevelCommand | CycleThinkingLevelCommand | CompactCommand | SetAutoCompactionCommand | GetStateCommand | GetMessagesCommand | NewSessionCommand | GetSessionStatsCommand | GetSessionMetaCommand | GetCommandsCommand | CompleteArgsCommand | CompleteFileRefsCommand | SetSessionNameCommand | DequeueSteeringCommand | ForkCommand | NavigateTreeCommand | SetTreeLabelCommand | CreateProjectCommand | RenameSessionCommand | ListFoldersCommand | ListSessionsCommand | OpenSessionCommand | CloseSessionCommand | DeleteSessionCommand | ArchiveSessionCommand | TakeoverFolderCommand | ViewSessionCommand | RegisterPushCommand | UnregisterPushCommand | KillConflictingProcessesCommand | KillConflictingSessionsCommand | ExtensionUiResponseCommand | CallBindCommand | CallEndCommand | LoginListCommand | LoginBeginCommand | LoginInputCommand | LoginCancelCommand | LogoutCommand;
355
409
  interface SessionEventBase {
356
410
  /** Which session produced this event */
357
411
  sessionId: string;
@@ -628,11 +682,54 @@ export interface VoiceInterruptEntryData {
628
682
  heard_text: string;
629
683
  kind: 'abort' | 'rollback';
630
684
  }
685
+ /**
686
+ * A single step in an interactive login flow, server → client. Rendered
687
+ * generically by the login modal. `prompt`/`select` steps carry a `requestId`
688
+ * the client echoes back via `login_input` to resolve the pending input. The
689
+ * `done` step is terminal. Login is global (not session-scoped), so this event
690
+ * carries no `sessionId`.
691
+ */
692
+ export type LoginStep = {
693
+ kind: 'auth';
694
+ url: string;
695
+ instructions?: string;
696
+ } | {
697
+ kind: 'device_code';
698
+ userCode: string;
699
+ verificationUri: string;
700
+ expiresInSeconds?: number;
701
+ } | {
702
+ kind: 'prompt';
703
+ requestId: string;
704
+ message: string;
705
+ placeholder?: string;
706
+ allowEmpty?: boolean;
707
+ } | {
708
+ kind: 'select';
709
+ requestId: string;
710
+ message: string;
711
+ options: {
712
+ id: string;
713
+ label: string;
714
+ }[];
715
+ } | {
716
+ kind: 'progress';
717
+ message: string;
718
+ } | {
719
+ kind: 'done';
720
+ success: boolean;
721
+ providerName: string;
722
+ error?: string;
723
+ };
724
+ export interface LoginStepEvent {
725
+ type: 'login_step';
726
+ step: LoginStep;
727
+ }
631
728
  export interface VersionMismatchEvent {
632
729
  type: 'version_mismatch';
633
730
  serverVersion: string;
634
731
  }
635
- export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | NavigateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | VersionMismatchEvent;
732
+ export type PimoteEvent = PimoteSessionEvent | ExtensionUiRequestEvent | SessionConflictEvent | SessionOpenedEvent | SessionClosedEvent | SessionDeletedEvent | SessionRenamedEvent | SessionArchivedEvent | SessionReplacedEvent | SessionStateChangedEvent | ConnectionRestoredEvent | SessionRestoreEvent | BufferedEventsEvent | FullResyncEvent | PanelUpdateEvent | NavigateEvent | CallBindResponse | CallReadyEvent | CallEndedEvent | CallStatusEvent | LoginStepEvent | VersionMismatchEvent;
636
733
  export interface PimoteResponse<T = unknown> {
637
734
  /** Matches the `id` from the originating PimoteCommand */
638
735
  id: string;