@pimote/pimote 0.6.0 → 0.7.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 (50) hide show
  1. package/README.md +4 -1
  2. package/client/build/_app/immutable/assets/0.-er3OUWm.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/{DNqQZw5U.js → B1ItOytB.js} +2 -2
  5. package/client/build/_app/immutable/chunks/BiEvVL3P.js +1 -0
  6. package/client/build/_app/immutable/chunks/D8SptH3Y.js +1 -0
  7. package/client/build/_app/immutable/chunks/S8e8sMop.js +1 -0
  8. package/client/build/_app/immutable/chunks/{DHiuV2ft.js → b9CWRTHL.js} +1 -1
  9. package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.agj-hcVA.js} +2 -2
  10. package/client/build/_app/immutable/entry/start.NVZAE6Px.js +1 -0
  11. package/client/build/_app/immutable/nodes/0.DweM6Pbc.js +10 -0
  12. package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.owr_UHNy.js} +1 -1
  13. package/client/build/_app/immutable/nodes/2.CQNU1AJj.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 +105 -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 +190 -63
  43. package/shared/dist/protocol.d.ts +91 -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/DegHYiTr.js +0 -1
  48. package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
  49. package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
  50. 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,57 @@ 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
+ }
667
738
  default: {
668
739
  this.sendResponse(id, false, undefined, `Unknown command type: ${command.type}`);
669
740
  }
@@ -694,7 +765,7 @@ export class WsHandler {
694
765
  if (trimmed === '/new') {
695
766
  const result = await slot.runtime.newSession();
696
767
  if (!result.cancelled)
697
- await slot.connection?.onSessionReset?.(slot);
768
+ await this.sessionManager.applySessionReset(slot);
698
769
  this.sendResponse(id, true, { success: !result.cancelled });
699
770
  break;
700
771
  }
@@ -737,7 +808,15 @@ export class WsHandler {
737
808
  case 'abort': {
738
809
  // Resolve pending UI responses first so stuck dialogs unblock
739
810
  resolveAllSlotPendingUi(slot);
740
- const abortResult = await Promise.race([session.abort().then(() => 'ok'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000))]);
811
+ let abortWatchdog;
812
+ const abortResult = await Promise.race([
813
+ session.abort().then(() => 'ok'),
814
+ new Promise((resolve) => {
815
+ abortWatchdog = setTimeout(() => resolve('timeout'), 30_000);
816
+ }),
817
+ ]);
818
+ if (abortWatchdog !== undefined)
819
+ clearTimeout(abortWatchdog);
741
820
  if (abortResult === 'timeout') {
742
821
  console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
743
822
  }
@@ -826,7 +905,7 @@ export class WsHandler {
826
905
  case 'new_session': {
827
906
  const result = await slot.runtime.newSession();
828
907
  if (!result.cancelled)
829
- await slot.connection?.onSessionReset?.(slot);
908
+ await this.sessionManager.applySessionReset(slot);
830
909
  this.sendResponse(id, true, { success: !result.cancelled });
831
910
  break;
832
911
  }
@@ -838,9 +917,14 @@ export class WsHandler {
838
917
  case 'get_session_meta': {
839
918
  const contextUsage = session.getContextUsage();
840
919
  const meta = {
841
- gitBranch: getGitBranch(slot.folderPath),
920
+ gitBranch: this.sessionManager.getLastKnownGitBranch(sessionId),
842
921
  contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
843
- lifetimeCostUsd: sumAssistantCostUsd(session.sessionManager.getBranch()),
922
+ // getEntries() (not getBranch()) so cost spans ALL branches in the
923
+ // session file, not just the current leaf's branch. Survives live
924
+ // switches and reload-from-disk because it is recomputed from the
925
+ // session manager's rehydrated entries every call. See session-cost.ts
926
+ // for what this figure excludes.
927
+ lifetimeCostUsd: sumAssistantCostUsd(session.sessionManager.getEntries()),
844
928
  };
845
929
  this.sendResponse(id, true, { meta });
846
930
  break;
@@ -874,7 +958,7 @@ export class WsHandler {
874
958
  });
875
959
  }
876
960
  // 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 });
961
+ 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 });
878
962
  this.sendResponse(id, true, { commands });
879
963
  break;
880
964
  }
@@ -893,6 +977,14 @@ export class WsHandler {
893
977
  this.sendResponse(id, true, { items: items ?? null });
894
978
  break;
895
979
  }
980
+ case 'complete_file_refs': {
981
+ const result = await completeFileRefs({ prefix: command.prefix, cwd: slot.folderPath });
982
+ if (!result.fdAvailable) {
983
+ this.emitFdMissingWarning(sessionId);
984
+ }
985
+ this.sendResponse(id, true, { items: result.items });
986
+ break;
987
+ }
896
988
  case 'set_session_name': {
897
989
  session.setSessionName(command.name);
898
990
  this.sendResponse(id, true);
@@ -905,7 +997,7 @@ export class WsHandler {
905
997
  }
906
998
  const forkResult = await slot.runtime.fork(command.entryId);
907
999
  if (!forkResult.cancelled) {
908
- await this.handleSessionReset(slot);
1000
+ await this.sessionManager.applySessionReset(slot);
909
1001
  }
910
1002
  const forkData = { cancelled: forkResult.cancelled };
911
1003
  if (forkResult.selectedText !== undefined) {
@@ -945,7 +1037,7 @@ export class WsHandler {
945
1037
  });
946
1038
  }
947
1039
  if (!result.cancelled) {
948
- await this.handleSessionReset(slot);
1040
+ await this.sessionManager.applySessionReset(slot);
949
1041
  }
950
1042
  const data = { cancelled: result.cancelled };
951
1043
  if (result.editorText !== undefined) {
@@ -986,12 +1078,14 @@ export class WsHandler {
986
1078
  }
987
1079
  }
988
1080
  /** Bind a slot to this client — sets ownership, WebSocket routing,
989
- * and subscribes to events. Extensions are bound once on first claim. */
1081
+ * and subscribes to events. Extensions are bound once on first claim. Public
1082
+ * so the voice force-bind path (displaceOwner wiring) can transfer ownership
1083
+ * through this same single operation, exactly like open_session does. */
990
1084
  async claimSession(sessionId, slot) {
991
1085
  const connection = {
992
1086
  ws: this.ws,
993
1087
  connectedClientId: this.clientId,
994
- onSessionReset: (s) => this.handleSessionReset(s),
1088
+ onSessionReset: (s, outcome) => this.handleSessionReset(s, outcome),
995
1089
  };
996
1090
  slot.connection = connection;
997
1091
  // Note: do NOT touch `idleSince` here. Idleness is an agent-level concept driven by
@@ -1004,50 +1098,42 @@ export class WsHandler {
1004
1098
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1005
1099
  isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(sessionId) ?? false,
1006
1100
  });
1007
- const commandContextActions = createCommandContextActions(slot);
1101
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1008
1102
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1009
1103
  slot.sessionState.extensionsBound = true;
1010
1104
  }
1011
1105
  // Re-deliver any pending UI requests to the new client (recovers lost dialogs)
1012
1106
  replaySlotPendingUiRequests(slot);
1013
1107
  }
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);
1108
+ /** Notify-only reaction to a session reset that the session manager has ALREADY
1109
+ * reconciled in the map (rebuild + collision-evict + re-key). Installed as the
1110
+ * owning connection's onSessionReset; runs only for the slot's current owner.
1111
+ * Does no map mutation itself — see SessionManager.applySessionReset. */
1112
+ async handleSessionReset(slot, outcome) {
1113
+ // navigateTree stays in the same file — same session ID, just resync.
1114
+ if (outcome.kind === 'unchanged') {
1115
+ this.sendFullResyncForSession(slot.sessionState.id, slot);
1022
1116
  return;
1023
1117
  }
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);
1118
+ const { oldId, newId, folderPath } = outcome;
1033
1119
  // Update handler bookkeeping
1034
- this.subscribedSessions.delete(oldSessionId);
1035
- this.subscribedSessions.add(newSessionId);
1036
- if (this.viewedSessionId === oldSessionId) {
1037
- this.viewedSessionId = newSessionId;
1120
+ this.subscribedSessions.delete(oldId);
1121
+ this.subscribedSessions.add(newId);
1122
+ if (this.viewedSessionId === oldId) {
1123
+ this.viewedSessionId = newId;
1038
1124
  }
1039
1125
  // Rebind extension UI bridge (new session state for dialog routing)
1040
1126
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1041
- isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newSessionId) ?? false,
1127
+ isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newId) ?? false,
1042
1128
  });
1043
- const commandContextActions = createCommandContextActions(slot);
1129
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1044
1130
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1045
1131
  slot.sessionState.extensionsBound = true;
1046
1132
  // Notify owning client: session replaced (client re-keys in place)
1047
1133
  this.sendEvent({
1048
1134
  type: 'session_replaced',
1049
- oldSessionId,
1050
- newSessionId,
1135
+ oldSessionId: oldId,
1136
+ newSessionId: newId,
1051
1137
  folder: {
1052
1138
  path: folderPath,
1053
1139
  name: folderPath.split('/').pop() ?? folderPath,
@@ -1057,8 +1143,22 @@ export class WsHandler {
1057
1143
  },
1058
1144
  });
1059
1145
  // 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);
1146
+ WsHandler.broadcastSidebarUpdate(oldId, folderPath, this.sessionManager, this.clientRegistry);
1147
+ WsHandler.broadcastSidebarUpdate(newId, folderPath, this.sessionManager, this.clientRegistry);
1148
+ }
1149
+ /** Surface the fd-missing warning at most once per connection (fire-and-forget toast). */
1150
+ emitFdMissingWarning(sessionId) {
1151
+ if (this.fdWarningEmitted)
1152
+ return;
1153
+ this.fdWarningEmitted = true;
1154
+ this.sendEvent({
1155
+ type: 'extension_ui_request',
1156
+ sessionId,
1157
+ requestId: `fd-missing-${Date.now()}`,
1158
+ method: 'notify',
1159
+ message: 'fd not found — file autocomplete is unavailable. Install fd to enable it.',
1160
+ notifyType: 'warning',
1161
+ });
1062
1162
  }
1063
1163
  buildFolderInfo(folderPath) {
1064
1164
  return {
@@ -1156,7 +1256,9 @@ export class WsHandler {
1156
1256
  // orchestrator bookkeeping and surface `call_ended { reason: 'displaced' }`
1157
1257
  // so their VoiceCallStore tears down alongside the session_closed.
1158
1258
  if (this.voiceOrchestrator?.isCallActive(sessionId)) {
1159
- void this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' });
1259
+ this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' }).catch((err) => {
1260
+ console.warn('[voice] endCall on displace failed', err);
1261
+ });
1160
1262
  this.sendEvent({
1161
1263
  type: 'call_ended',
1162
1264
  sessionId,
@@ -1217,8 +1319,39 @@ export class WsHandler {
1217
1319
  console.error('[WsHandler] Failed to send event:', err);
1218
1320
  }
1219
1321
  }
1322
+ /** Reject + clear any outstanding login prompt/select inputs for this
1323
+ * connection. pi attaches a `.catch` to the manual-code promise, so rejecting
1324
+ * is safe and won't surface an unhandled rejection. */
1325
+ settlePendingLoginInputs(reason = 'login flow ended') {
1326
+ for (const [, pending] of this.pendingLoginInputs) {
1327
+ pending.reject(new Error(reason));
1328
+ }
1329
+ this.pendingLoginInputs.clear();
1330
+ }
1331
+ /** Build a connection-bound LoginTransport: events flow to this client, and
1332
+ * prompt/select inputs resolve via this connection's pendingLoginInputs map. */
1333
+ createLoginTransport() {
1334
+ const controller = new AbortController();
1335
+ this.loginAbort = controller;
1336
+ const awaitInput = (requestId) => new Promise((resolve, reject) => {
1337
+ this.pendingLoginInputs.set(requestId, { resolve, reject });
1338
+ });
1339
+ return {
1340
+ emit: (step) => {
1341
+ this.sendEvent({ type: 'login_step', step });
1342
+ },
1343
+ requestInput: ({ requestId, message, placeholder, allowEmpty }) => {
1344
+ this.sendEvent({ type: 'login_step', step: { kind: 'prompt', requestId, message, placeholder, allowEmpty } });
1345
+ return awaitInput(requestId);
1346
+ },
1347
+ requestSelect: ({ requestId, message, options }) => {
1348
+ this.sendEvent({ type: 'login_step', step: { kind: 'select', requestId, message, options } });
1349
+ return awaitInput(requestId);
1350
+ },
1351
+ signal: controller.signal,
1352
+ };
1353
+ }
1220
1354
  emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
1221
- let forwarded = false;
1222
1355
  slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
1223
1356
  // Augment agent_end with message entry IDs so the client can enable
1224
1357
  // fork targets on messages that arrived via streaming (without IDs).
@@ -1226,20 +1359,8 @@ export class WsHandler {
1226
1359
  const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
1227
1360
  event.messageEntryIds = entryIds;
1228
1361
  }
1229
- forwarded = true;
1230
1362
  this.sendEvent(event);
1231
1363
  }, () => 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
1364
  }
1244
1365
  /** Send a full_resync event to the client for the given managed session.
1245
1366
  * Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
@@ -1316,13 +1437,19 @@ export class WsHandler {
1316
1437
  sessionName,
1317
1438
  firstMessage,
1318
1439
  messageCount,
1319
- gitBranch: slot ? getGitBranch(slot.folderPath) : null,
1440
+ gitBranch: slot ? sessionManager.getLastKnownGitBranch(sessionId) : null,
1320
1441
  };
1321
1442
  for (const [, handler] of clientRegistry) {
1322
1443
  handler.sendToClient(event);
1323
1444
  }
1324
1445
  }
1325
1446
  cleanup() {
1447
+ // If this connection had a login flow in flight, abort it and settle any
1448
+ // dangling input promises (mirror of `login_cancel`). The LoginOrchestrator
1449
+ // is single-flight and server-wide, so leaving it busy would wedge logins
1450
+ // for every client until restart.
1451
+ this.loginAbort?.abort();
1452
+ this.settlePendingLoginInputs('connection closed');
1326
1453
  for (const sid of this.subscribedSessions) {
1327
1454
  const slot = this.sessionManager.getSession(sid);
1328
1455
  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,39 @@ 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
+ export interface LoginListResponseData {
386
+ providers: LoginProviderInfo[];
387
+ }
388
+ export interface LoginBeginResponseData {
389
+ ok: boolean;
390
+ /** Present when ok === false because a login flow is already in progress. */
391
+ reason?: 'busy';
392
+ }
347
393
  export interface ExtensionUiResponseCommand extends CommandBase {
348
394
  type: 'extension_ui_response';
349
395
  requestId: string;
@@ -351,7 +397,7 @@ export interface ExtensionUiResponseCommand extends CommandBase {
351
397
  confirmed?: boolean;
352
398
  cancelled?: boolean;
353
399
  }
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;
400
+ 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;
355
401
  interface SessionEventBase {
356
402
  /** Which session produced this event */
357
403
  sessionId: string;
@@ -628,11 +674,54 @@ export interface VoiceInterruptEntryData {
628
674
  heard_text: string;
629
675
  kind: 'abort' | 'rollback';
630
676
  }
677
+ /**
678
+ * A single step in an interactive login flow, server → client. Rendered
679
+ * generically by the login modal. `prompt`/`select` steps carry a `requestId`
680
+ * the client echoes back via `login_input` to resolve the pending input. The
681
+ * `done` step is terminal. Login is global (not session-scoped), so this event
682
+ * carries no `sessionId`.
683
+ */
684
+ export type LoginStep = {
685
+ kind: 'auth';
686
+ url: string;
687
+ instructions?: string;
688
+ } | {
689
+ kind: 'device_code';
690
+ userCode: string;
691
+ verificationUri: string;
692
+ expiresInSeconds?: number;
693
+ } | {
694
+ kind: 'prompt';
695
+ requestId: string;
696
+ message: string;
697
+ placeholder?: string;
698
+ allowEmpty?: boolean;
699
+ } | {
700
+ kind: 'select';
701
+ requestId: string;
702
+ message: string;
703
+ options: {
704
+ id: string;
705
+ label: string;
706
+ }[];
707
+ } | {
708
+ kind: 'progress';
709
+ message: string;
710
+ } | {
711
+ kind: 'done';
712
+ success: boolean;
713
+ providerName: string;
714
+ error?: string;
715
+ };
716
+ export interface LoginStepEvent {
717
+ type: 'login_step';
718
+ step: LoginStep;
719
+ }
631
720
  export interface VersionMismatchEvent {
632
721
  type: 'version_mismatch';
633
722
  serverVersion: string;
634
723
  }
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;
724
+ 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
725
  export interface PimoteResponse<T = unknown> {
637
726
  /** Matches the `id` from the originating PimoteCommand */
638
727
  id: string;