@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.
- package/README.md +4 -1
- package/client/build/_app/immutable/assets/0.-er3OUWm.css +2 -0
- package/client/build/_app/immutable/assets/{2.bfMycywk.css → 2.BtlPyuHL.css} +1 -1
- package/client/build/_app/immutable/chunks/{DNqQZw5U.js → B1ItOytB.js} +2 -2
- package/client/build/_app/immutable/chunks/BiEvVL3P.js +1 -0
- package/client/build/_app/immutable/chunks/D8SptH3Y.js +1 -0
- package/client/build/_app/immutable/chunks/S8e8sMop.js +1 -0
- package/client/build/_app/immutable/chunks/{DHiuV2ft.js → b9CWRTHL.js} +1 -1
- package/client/build/_app/immutable/entry/{app.DZYoujEP.js → app.agj-hcVA.js} +2 -2
- package/client/build/_app/immutable/entry/start.NVZAE6Px.js +1 -0
- package/client/build/_app/immutable/nodes/0.DweM6Pbc.js +10 -0
- package/client/build/_app/immutable/nodes/{1.B5qlqMFD.js → 1.owr_UHNy.js} +1 -1
- package/client/build/_app/immutable/nodes/2.CQNU1AJj.js +55 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +7 -7
- package/package.json +2 -2
- package/server/dist/config.js +5 -2
- package/server/dist/event-buffer.js +9 -0
- package/server/dist/extension-ui-bridge.js +26 -10
- package/server/dist/file-references.js +123 -0
- package/server/dist/git-branch.js +12 -9
- package/server/dist/login-orchestrator.js +105 -0
- package/server/dist/push-infrastructure.js +13 -2
- package/server/dist/push-notification.js +18 -11
- package/server/dist/server.js +25 -2
- package/server/dist/session-cost.js +26 -2
- package/server/dist/session-manager.js +109 -6
- package/server/dist/static-host/gc.js +13 -0
- package/server/dist/static-host/http-handler.js +27 -1
- package/server/dist/static-host/index.js +24 -12
- package/server/dist/static-host/store.js +10 -1
- package/server/dist/static-host/tools.js +5 -1
- package/server/dist/voice/fsm/reducer.js +14 -2
- package/server/dist/voice/fsm/reducers/lifecycle.js +10 -4
- package/server/dist/voice/fsm/reducers/streaming.js +39 -3
- package/server/dist/voice/fsm/reducers/walkback.js +13 -10
- package/server/dist/voice/fsm/state.js +1 -1
- package/server/dist/voice/index.js +97 -41
- package/server/dist/voice/walk-back.js +94 -26
- package/server/dist/voice-orchestrator-boot.js +22 -5
- package/server/dist/voice-orchestrator.js +38 -1
- package/server/dist/ws-handler.js +190 -63
- package/shared/dist/protocol.d.ts +91 -2
- package/client/build/_app/immutable/assets/0.Dh2gYJ1J.css +0 -2
- package/client/build/_app/immutable/chunks/Czpnrh9t.js +0 -1
- package/client/build/_app/immutable/chunks/D1mCuOEu.js +0 -1
- package/client/build/_app/immutable/chunks/DegHYiTr.js +0 -1
- package/client/build/_app/immutable/entry/start.BNnDRfmt.js +0 -1
- package/client/build/_app/immutable/nodes/0.B20DMuGn.js +0 -10
- 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
|
|
88
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
920
|
+
gitBranch: this.sessionManager.getLastKnownGitBranch(sessionId),
|
|
842
921
|
contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
|
|
843
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
1015
|
-
*
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
// navigateTree stays in the same file — same session ID, just resync
|
|
1020
|
-
if (
|
|
1021
|
-
this.sendFullResyncForSession(
|
|
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
|
-
|
|
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(
|
|
1035
|
-
this.subscribedSessions.add(
|
|
1036
|
-
if (this.viewedSessionId ===
|
|
1037
|
-
this.viewedSessionId =
|
|
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(
|
|
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(
|
|
1061
|
-
WsHandler.broadcastSidebarUpdate(
|
|
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
|
-
|
|
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 ?
|
|
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;
|