@pimote/pimote 0.5.1 → 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 (55) 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.BtlPyuHL.css +1 -0
  4. package/client/build/_app/immutable/chunks/ATalJV7d.js +3 -0
  5. package/client/build/_app/immutable/chunks/{eHWBE-tD.js → B1ItOytB.js} +2 -2
  6. package/client/build/_app/immutable/chunks/BiEvVL3P.js +1 -0
  7. package/client/build/_app/immutable/chunks/D8SptH3Y.js +1 -0
  8. package/client/build/_app/immutable/chunks/S8e8sMop.js +1 -0
  9. package/client/build/_app/immutable/chunks/b9CWRTHL.js +1 -0
  10. package/client/build/_app/immutable/entry/{app.Di2WQBl6.js → app.agj-hcVA.js} +2 -2
  11. package/client/build/_app/immutable/entry/start.NVZAE6Px.js +1 -0
  12. package/client/build/_app/immutable/nodes/0.DweM6Pbc.js +10 -0
  13. package/client/build/_app/immutable/nodes/{1.DKkktqMe.js → 1.owr_UHNy.js} +1 -1
  14. package/client/build/_app/immutable/nodes/2.CQNU1AJj.js +55 -0
  15. package/client/build/_app/version.json +1 -1
  16. package/client/build/index.html +7 -7
  17. package/package.json +2 -2
  18. package/server/dist/config.js +5 -2
  19. package/server/dist/event-buffer.js +10 -1
  20. package/server/dist/extension-ui-bridge.js +26 -10
  21. package/server/dist/file-references.js +123 -0
  22. package/server/dist/git-branch.js +12 -9
  23. package/server/dist/login-orchestrator.js +105 -0
  24. package/server/dist/push-infrastructure.js +13 -2
  25. package/server/dist/push-notification.js +18 -11
  26. package/server/dist/server.js +25 -2
  27. package/server/dist/session-cost.js +26 -2
  28. package/server/dist/session-manager.js +116 -7
  29. package/server/dist/static-host/gc.js +13 -0
  30. package/server/dist/static-host/http-handler.js +27 -1
  31. package/server/dist/static-host/index.js +24 -12
  32. package/server/dist/static-host/store.js +10 -1
  33. package/server/dist/static-host/tools.js +5 -1
  34. package/server/dist/voice/fsm/reducer.js +14 -2
  35. package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
  36. package/server/dist/voice/fsm/reducers/streaming.js +39 -3
  37. package/server/dist/voice/fsm/reducers/walkback.js +13 -10
  38. package/server/dist/voice/fsm/state.js +1 -1
  39. package/server/dist/voice/index.js +97 -41
  40. package/server/dist/voice/walk-back.js +94 -26
  41. package/server/dist/voice-orchestrator-boot.js +22 -5
  42. package/server/dist/voice-orchestrator.js +38 -1
  43. package/server/dist/ws-handler.js +194 -64
  44. package/shared/dist/protocol.d.ts +97 -2
  45. package/client/build/_app/immutable/assets/0.KP1suSk9.css +0 -2
  46. package/client/build/_app/immutable/assets/2.BaqEkCa-.css +0 -1
  47. package/client/build/_app/immutable/chunks/0-bXzYW9.js +0 -1
  48. package/client/build/_app/immutable/chunks/BgJ-X-tf.js +0 -3
  49. package/client/build/_app/immutable/chunks/CnTTbAN2.js +0 -1
  50. package/client/build/_app/immutable/chunks/RbcwTVzu.js +0 -1
  51. package/client/build/_app/immutable/chunks/TV35yyBT.js +0 -1
  52. package/client/build/_app/immutable/chunks/gZLAQ0sf.js +0 -1
  53. package/client/build/_app/immutable/entry/start.ClOWBB7j.js +0 -1
  54. package/client/build/_app/immutable/nodes/0.DJUqUGM7.js +0 -10
  55. package/client/build/_app/immutable/nodes/2.BTjJ9cu5.js +0 -54
@@ -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,17 +85,21 @@ 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)
94
96
  return Promise.resolve();
95
97
  return new Promise((resolve) => {
96
98
  const unsubscribe = slot.session.subscribe((event) => {
97
- if (event.type === 'agent_end') {
99
+ // A `willRetry` agent_end is not a real end — the SDK will re-run the
100
+ // prompt after backoff. Resolving here would hand control back mid-run.
101
+ // Wait for the terminal (willRetry=false) agent_end instead.
102
+ if (event.type === 'agent_end' && !event.willRetry) {
98
103
  unsubscribe();
99
104
  resolve();
100
105
  }
@@ -104,25 +109,25 @@ function createCommandContextActions(slot) {
104
109
  newSession: async (options) => {
105
110
  const result = await slot.runtime.newSession(options);
106
111
  if (!result.cancelled)
107
- await slot.connection?.onSessionReset?.(slot);
112
+ await sessionManager.applySessionReset(slot);
108
113
  return { cancelled: result.cancelled };
109
114
  },
110
115
  fork: async (entryId) => {
111
116
  const result = await slot.runtime.fork(entryId);
112
117
  if (!result.cancelled)
113
- await slot.connection?.onSessionReset?.(slot);
118
+ await sessionManager.applySessionReset(slot);
114
119
  return { cancelled: result.cancelled };
115
120
  },
116
121
  navigateTree: async (targetId, options) => {
117
122
  const result = await slot.session.navigateTree(targetId, options);
118
123
  if (!result.cancelled)
119
- await slot.connection?.onSessionReset?.(slot);
124
+ await sessionManager.applySessionReset(slot);
120
125
  return { cancelled: result.cancelled };
121
126
  },
122
127
  switchSession: async (sessionPath) => {
123
128
  const result = await slot.runtime.switchSession(sessionPath);
124
129
  if (!result.cancelled)
125
- await slot.connection?.onSessionReset?.(slot);
130
+ await sessionManager.applySessionReset(slot);
126
131
  return { cancelled: result.cancelled };
127
132
  },
128
133
  reload: () => slot.session.reload(),
@@ -137,7 +142,12 @@ export class WsHandler {
137
142
  clientRegistry;
138
143
  voiceOrchestrator;
139
144
  subscribedSessions = new Set();
145
+ fdWarningEmitted = false;
140
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;
141
151
  clientId;
142
152
  constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator) {
143
153
  this.sessionManager = sessionManager;
@@ -161,6 +171,12 @@ export class WsHandler {
161
171
  this.sendResponse('unknown', false, undefined, 'Invalid JSON');
162
172
  return;
163
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
+ }
164
180
  const id = command.id ?? 'unknown';
165
181
  try {
166
182
  switch (command.type) {
@@ -498,7 +514,7 @@ export class WsHandler {
498
514
  const connection = {
499
515
  ws: this.ws,
500
516
  connectedClientId: this.clientId,
501
- onSessionReset: (s) => this.handleSessionReset(s),
517
+ onSessionReset: (s, outcome) => this.handleSessionReset(s, outcome),
502
518
  };
503
519
  try {
504
520
  const data = await this.voiceOrchestrator.bindCall({
@@ -521,9 +537,15 @@ export class WsHandler {
521
537
  break;
522
538
  }
523
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;
524
545
  await this.voiceOrchestrator?.endCall({ sessionId: command.sessionId, reason: 'user_hangup' });
525
546
  this.sendResponse(id, true);
526
- 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');
527
549
  break;
528
550
  }
529
551
  // ---- Client diagnostic logs (voice/call tracing) ----
@@ -653,6 +675,7 @@ export class WsHandler {
653
675
  case 'get_session_meta':
654
676
  case 'get_commands':
655
677
  case 'complete_args':
678
+ case 'complete_file_refs':
656
679
  case 'set_session_name':
657
680
  case 'dequeue_steering':
658
681
  case 'fork':
@@ -661,6 +684,57 @@ export class WsHandler {
661
684
  await this.handleSessionCommand(command, id);
662
685
  break;
663
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
+ }
664
738
  default: {
665
739
  this.sendResponse(id, false, undefined, `Unknown command type: ${command.type}`);
666
740
  }
@@ -691,7 +765,7 @@ export class WsHandler {
691
765
  if (trimmed === '/new') {
692
766
  const result = await slot.runtime.newSession();
693
767
  if (!result.cancelled)
694
- await slot.connection?.onSessionReset?.(slot);
768
+ await this.sessionManager.applySessionReset(slot);
695
769
  this.sendResponse(id, true, { success: !result.cancelled });
696
770
  break;
697
771
  }
@@ -734,7 +808,15 @@ export class WsHandler {
734
808
  case 'abort': {
735
809
  // Resolve pending UI responses first so stuck dialogs unblock
736
810
  resolveAllSlotPendingUi(slot);
737
- 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);
738
820
  if (abortResult === 'timeout') {
739
821
  console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
740
822
  }
@@ -823,7 +905,7 @@ export class WsHandler {
823
905
  case 'new_session': {
824
906
  const result = await slot.runtime.newSession();
825
907
  if (!result.cancelled)
826
- await slot.connection?.onSessionReset?.(slot);
908
+ await this.sessionManager.applySessionReset(slot);
827
909
  this.sendResponse(id, true, { success: !result.cancelled });
828
910
  break;
829
911
  }
@@ -835,9 +917,14 @@ export class WsHandler {
835
917
  case 'get_session_meta': {
836
918
  const contextUsage = session.getContextUsage();
837
919
  const meta = {
838
- gitBranch: getGitBranch(slot.folderPath),
920
+ gitBranch: this.sessionManager.getLastKnownGitBranch(sessionId),
839
921
  contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
840
- 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()),
841
928
  };
842
929
  this.sendResponse(id, true, { meta });
843
930
  break;
@@ -871,7 +958,7 @@ export class WsHandler {
871
958
  });
872
959
  }
873
960
  // Pimote built-in commands
874
- 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 });
875
962
  this.sendResponse(id, true, { commands });
876
963
  break;
877
964
  }
@@ -890,6 +977,14 @@ export class WsHandler {
890
977
  this.sendResponse(id, true, { items: items ?? null });
891
978
  break;
892
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
+ }
893
988
  case 'set_session_name': {
894
989
  session.setSessionName(command.name);
895
990
  this.sendResponse(id, true);
@@ -902,7 +997,7 @@ export class WsHandler {
902
997
  }
903
998
  const forkResult = await slot.runtime.fork(command.entryId);
904
999
  if (!forkResult.cancelled) {
905
- await this.handleSessionReset(slot);
1000
+ await this.sessionManager.applySessionReset(slot);
906
1001
  }
907
1002
  const forkData = { cancelled: forkResult.cancelled };
908
1003
  if (forkResult.selectedText !== undefined) {
@@ -942,7 +1037,7 @@ export class WsHandler {
942
1037
  });
943
1038
  }
944
1039
  if (!result.cancelled) {
945
- await this.handleSessionReset(slot);
1040
+ await this.sessionManager.applySessionReset(slot);
946
1041
  }
947
1042
  const data = { cancelled: result.cancelled };
948
1043
  if (result.editorText !== undefined) {
@@ -983,12 +1078,14 @@ export class WsHandler {
983
1078
  }
984
1079
  }
985
1080
  /** Bind a slot to this client — sets ownership, WebSocket routing,
986
- * 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. */
987
1084
  async claimSession(sessionId, slot) {
988
1085
  const connection = {
989
1086
  ws: this.ws,
990
1087
  connectedClientId: this.clientId,
991
- onSessionReset: (s) => this.handleSessionReset(s),
1088
+ onSessionReset: (s, outcome) => this.handleSessionReset(s, outcome),
992
1089
  };
993
1090
  slot.connection = connection;
994
1091
  // Note: do NOT touch `idleSince` here. Idleness is an agent-level concept driven by
@@ -1001,50 +1098,42 @@ export class WsHandler {
1001
1098
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1002
1099
  isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(sessionId) ?? false,
1003
1100
  });
1004
- const commandContextActions = createCommandContextActions(slot);
1101
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1005
1102
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1006
1103
  slot.sessionState.extensionsBound = true;
1007
1104
  }
1008
1105
  // Re-deliver any pending UI requests to the new client (recovers lost dialogs)
1009
1106
  replaySlotPendingUiRequests(slot);
1010
1107
  }
1011
- /** Handle a session reset (newSession, fork, switchSession).
1012
- * Called via slot.connection.onSessionReset after the runtime has replaced the session. */
1013
- async handleSessionReset(slot) {
1014
- const newSessionId = slot.runtime.session.sessionId;
1015
- const oldSessionId = slot.sessionState.id;
1016
- // navigateTree stays in the same file — same session ID, just resync
1017
- if (newSessionId === oldSessionId) {
1018
- 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);
1019
1116
  return;
1020
1117
  }
1021
- // Session ID changed rebuild session state in-place on the same slot.
1022
- // rebuildSessionState refreshes slot.folderPath from the new session's header cwd,
1023
- // so capture folderPath AFTER the rebuild to pick up the new value (fork-from can
1024
- // change cwd, e.g. the worktree extension).
1025
- // Rebuild session state (tears down old, creates new from runtime.session)
1026
- this.sessionManager.rebuildSessionState(slot);
1027
- const folderPath = slot.folderPath;
1028
- // Re-key the session map
1029
- this.sessionManager.reKeySession(slot, oldSessionId, newSessionId);
1118
+ const { oldId, newId, folderPath } = outcome;
1030
1119
  // Update handler bookkeeping
1031
- this.subscribedSessions.delete(oldSessionId);
1032
- this.subscribedSessions.add(newSessionId);
1033
- if (this.viewedSessionId === oldSessionId) {
1034
- this.viewedSessionId = newSessionId;
1120
+ this.subscribedSessions.delete(oldId);
1121
+ this.subscribedSessions.add(newId);
1122
+ if (this.viewedSessionId === oldId) {
1123
+ this.viewedSessionId = newId;
1035
1124
  }
1036
1125
  // Rebind extension UI bridge (new session state for dialog routing)
1037
1126
  const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
1038
- isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newSessionId) ?? false,
1127
+ isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newId) ?? false,
1039
1128
  });
1040
- const commandContextActions = createCommandContextActions(slot);
1129
+ const commandContextActions = createCommandContextActions(slot, this.sessionManager);
1041
1130
  await slot.session.bindExtensions({ uiContext, commandContextActions });
1042
1131
  slot.sessionState.extensionsBound = true;
1043
1132
  // Notify owning client: session replaced (client re-keys in place)
1044
1133
  this.sendEvent({
1045
1134
  type: 'session_replaced',
1046
- oldSessionId,
1047
- newSessionId,
1135
+ oldSessionId: oldId,
1136
+ newSessionId: newId,
1048
1137
  folder: {
1049
1138
  path: folderPath,
1050
1139
  name: folderPath.split('/').pop() ?? folderPath,
@@ -1054,8 +1143,22 @@ export class WsHandler {
1054
1143
  },
1055
1144
  });
1056
1145
  // Broadcast sidebar updates for both old (now inactive) and new (now active)
1057
- WsHandler.broadcastSidebarUpdate(oldSessionId, folderPath, this.sessionManager, this.clientRegistry);
1058
- 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
+ });
1059
1162
  }
1060
1163
  buildFolderInfo(folderPath) {
1061
1164
  return {
@@ -1153,7 +1256,9 @@ export class WsHandler {
1153
1256
  // orchestrator bookkeeping and surface `call_ended { reason: 'displaced' }`
1154
1257
  // so their VoiceCallStore tears down alongside the session_closed.
1155
1258
  if (this.voiceOrchestrator?.isCallActive(sessionId)) {
1156
- 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
+ });
1157
1262
  this.sendEvent({
1158
1263
  type: 'call_ended',
1159
1264
  sessionId,
@@ -1214,8 +1319,39 @@ export class WsHandler {
1214
1319
  console.error('[WsHandler] Failed to send event:', err);
1215
1320
  }
1216
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
+ }
1217
1354
  emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
1218
- let forwarded = false;
1219
1355
  slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
1220
1356
  // Augment agent_end with message entry IDs so the client can enable
1221
1357
  // fork targets on messages that arrived via streaming (without IDs).
@@ -1223,20 +1359,8 @@ export class WsHandler {
1223
1359
  const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
1224
1360
  event.messageEntryIds = entryIds;
1225
1361
  }
1226
- forwarded = true;
1227
1362
  this.sendEvent(event);
1228
1363
  }, () => slot.session.messages[slot.session.messages.length - 1]);
1229
- // Test doubles for EventBuffer may no-op on onEvent().
1230
- // Fallback keeps lifecycle visibility in tests while real runtime uses buffered forwarding above.
1231
- if (!forwarded) {
1232
- const cursorBase = slot.sessionState.eventBuffer.currentCursor;
1233
- const cursor = typeof cursorBase === 'number' ? cursorBase + 1 : 1;
1234
- this.sendEvent({
1235
- ...sdkEvent,
1236
- sessionId,
1237
- cursor,
1238
- });
1239
- }
1240
1364
  }
1241
1365
  /** Send a full_resync event to the client for the given managed session.
1242
1366
  * Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
@@ -1313,13 +1437,19 @@ export class WsHandler {
1313
1437
  sessionName,
1314
1438
  firstMessage,
1315
1439
  messageCount,
1316
- gitBranch: slot ? getGitBranch(slot.folderPath) : null,
1440
+ gitBranch: slot ? sessionManager.getLastKnownGitBranch(sessionId) : null,
1317
1441
  };
1318
1442
  for (const [, handler] of clientRegistry) {
1319
1443
  handler.sendToClient(event);
1320
1444
  }
1321
1445
  }
1322
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');
1323
1453
  for (const sid of this.subscribedSessions) {
1324
1454
  const slot = this.sessionManager.getSession(sid);
1325
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;
@@ -366,6 +412,12 @@ export interface AgentStartEvent extends SessionEventBase {
366
412
  export interface AgentEndEvent extends SessionEventBase {
367
413
  type: 'agent_end';
368
414
  error?: string;
415
+ /** True when this `agent_end` is followed by an automated retry (the SDK
416
+ * detected a retryable error and will re-run the prompt). When set, this
417
+ * is NOT a real end — a fresh `agent_start` will follow after backoff, so
418
+ * consumers must not treat it as the run finishing (no idle transition,
419
+ * no "needs attention", no completion notification). */
420
+ willRetry?: boolean;
369
421
  /** Entry IDs for all messages, zipped 1:1 with the session message list.
370
422
  * Sent so the client can enable fork targets on messages received via
371
423
  * streaming events (which don't carry entry IDs individually). */
@@ -622,11 +674,54 @@ export interface VoiceInterruptEntryData {
622
674
  heard_text: string;
623
675
  kind: 'abort' | 'rollback';
624
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
+ }
625
720
  export interface VersionMismatchEvent {
626
721
  type: 'version_mismatch';
627
722
  serverVersion: string;
628
723
  }
629
- 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;
630
725
  export interface PimoteResponse<T = unknown> {
631
726
  /** Matches the `id` from the originating PimoteCommand */
632
727
  id: string;