@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.
- package/README.md +4 -1
- package/client/build/_app/immutable/assets/0.-er3OUWm.css +2 -0
- package/client/build/_app/immutable/assets/2.BtlPyuHL.css +1 -0
- package/client/build/_app/immutable/chunks/ATalJV7d.js +3 -0
- package/client/build/_app/immutable/chunks/{eHWBE-tD.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/b9CWRTHL.js +1 -0
- package/client/build/_app/immutable/entry/{app.Di2WQBl6.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.DKkktqMe.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 +10 -1
- 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 +116 -7
- 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 +194 -64
- package/shared/dist/protocol.d.ts +97 -2
- package/client/build/_app/immutable/assets/0.KP1suSk9.css +0 -2
- package/client/build/_app/immutable/assets/2.BaqEkCa-.css +0 -1
- package/client/build/_app/immutable/chunks/0-bXzYW9.js +0 -1
- package/client/build/_app/immutable/chunks/BgJ-X-tf.js +0 -3
- package/client/build/_app/immutable/chunks/CnTTbAN2.js +0 -1
- package/client/build/_app/immutable/chunks/RbcwTVzu.js +0 -1
- package/client/build/_app/immutable/chunks/TV35yyBT.js +0 -1
- package/client/build/_app/immutable/chunks/gZLAQ0sf.js +0 -1
- package/client/build/_app/immutable/entry/start.ClOWBB7j.js +0 -1
- package/client/build/_app/immutable/nodes/0.DJUqUGM7.js +0 -10
- 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
|
|
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)
|
|
94
96
|
return Promise.resolve();
|
|
95
97
|
return new Promise((resolve) => {
|
|
96
98
|
const unsubscribe = slot.session.subscribe((event) => {
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
920
|
+
gitBranch: this.sessionManager.getLastKnownGitBranch(sessionId),
|
|
839
921
|
contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
|
|
840
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
1012
|
-
*
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
// navigateTree stays in the same file — same session ID, just resync
|
|
1017
|
-
if (
|
|
1018
|
-
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);
|
|
1019
1116
|
return;
|
|
1020
1117
|
}
|
|
1021
|
-
|
|
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(
|
|
1032
|
-
this.subscribedSessions.add(
|
|
1033
|
-
if (this.viewedSessionId ===
|
|
1034
|
-
this.viewedSessionId =
|
|
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(
|
|
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(
|
|
1058
|
-
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
|
+
});
|
|
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
|
-
|
|
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 ?
|
|
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;
|