@pimote/pimote 0.1.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/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/pimote.js +8 -0
- package/client/build/_app/env.js +1 -0
- package/client/build/_app/immutable/assets/0.CsjXJ2oE.css +2 -0
- package/client/build/_app/immutable/assets/2.CIRqqeIr.css +1 -0
- package/client/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/client/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/client/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/client/build/_app/immutable/chunks/5FogVG_p.js +1 -0
- package/client/build/_app/immutable/chunks/BN18Mjoo.js +1 -0
- package/client/build/_app/immutable/chunks/BTSGQ0LP.js +3 -0
- package/client/build/_app/immutable/chunks/BTW4yCoz.js +1 -0
- package/client/build/_app/immutable/chunks/BgJ-X-tf.js +3 -0
- package/client/build/_app/immutable/chunks/CHncfsjL.js +1 -0
- package/client/build/_app/immutable/chunks/CnTTbAN2.js +1 -0
- package/client/build/_app/immutable/chunks/CnuZs6QA.js +1 -0
- package/client/build/_app/immutable/chunks/CvWR-ThL.js +1 -0
- package/client/build/_app/immutable/chunks/D1hYfEew.js +1 -0
- package/client/build/_app/immutable/chunks/D5m3x_L9.js +5 -0
- package/client/build/_app/immutable/chunks/L5t1qIFa.js +50 -0
- package/client/build/_app/immutable/entry/app.BjHwmkZK.js +2 -0
- package/client/build/_app/immutable/entry/start.CZeUhs5D.js +1 -0
- package/client/build/_app/immutable/nodes/0.HHf1ps7Y.js +5 -0
- package/client/build/_app/immutable/nodes/1.CjbUSBAL.js +1 -0
- package/client/build/_app/immutable/nodes/2.C22f_gRz.js +49 -0
- package/client/build/_app/version.json +1 -0
- package/client/build/index.html +45 -0
- package/client/build/pwa/badge-96.png +0 -0
- package/client/build/pwa/icon-192.png +0 -0
- package/client/build/pwa/icon-512.png +0 -0
- package/client/build/pwa/manifest.json +39 -0
- package/client/build/robots.txt +3 -0
- package/client/build/sw.js +2 -0
- package/package.json +81 -0
- package/patches/@mariozechner+pi-coding-agent+0.65.0.patch +24 -0
- package/scripts/postinstall-patches.mjs +55 -0
- package/server/dist/cli.js +347 -0
- package/server/dist/config.js +78 -0
- package/server/dist/event-buffer.js +223 -0
- package/server/dist/extension-ui-bridge.js +175 -0
- package/server/dist/folder-index.js +126 -0
- package/server/dist/index.js +54 -0
- package/server/dist/message-mapper.js +80 -0
- package/server/dist/panel-state.js +28 -0
- package/server/dist/paths.js +14 -0
- package/server/dist/push-infrastructure.js +73 -0
- package/server/dist/push-notification.js +56 -0
- package/server/dist/server.js +223 -0
- package/server/dist/session-manager.js +313 -0
- package/server/dist/session-metadata.js +81 -0
- package/server/dist/takeover.js +172 -0
- package/server/dist/ws-handler.js +989 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { resolveAllSlotPendingUi, resolveSlotPendingUi, replaySlotPendingUiRequests } from './session-manager.js';
|
|
3
|
+
import { getMergedPanelCards } from './panel-state.js';
|
|
4
|
+
import { createExtensionUIBridge } from './extension-ui-bridge.js';
|
|
5
|
+
import { findExternalPiProcesses, killExternalPiProcesses } from './takeover.js';
|
|
6
|
+
import { mapAgentMessages } from './message-mapper.js';
|
|
7
|
+
/** Parse data-URL encoded images into the shape the pi SDK expects. */
|
|
8
|
+
function parseDataUrlImages(images) {
|
|
9
|
+
if (!images || images.length === 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
return images.map((url) => {
|
|
12
|
+
const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/s);
|
|
13
|
+
if (!match)
|
|
14
|
+
throw new Error('Invalid image data URL');
|
|
15
|
+
return { type: 'image', data: match[2], mimeType: match[1] };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create command context actions for extension commands.
|
|
20
|
+
* Captures the ManagedSlot (stable lifetime), not a transient handler.
|
|
21
|
+
* Session resets are routed through slot.connection.onSessionReset which the
|
|
22
|
+
* current handler sets on claim and clears on cleanup.
|
|
23
|
+
*/
|
|
24
|
+
function createCommandContextActions(slot) {
|
|
25
|
+
return {
|
|
26
|
+
waitForIdle: () => {
|
|
27
|
+
if (!slot.session.isStreaming)
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const unsubscribe = slot.session.subscribe((event) => {
|
|
31
|
+
if (event.type === 'agent_end') {
|
|
32
|
+
unsubscribe();
|
|
33
|
+
resolve();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
newSession: async (options) => {
|
|
39
|
+
const result = await slot.runtime.newSession(options);
|
|
40
|
+
if (!result.cancelled)
|
|
41
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
42
|
+
return { cancelled: result.cancelled };
|
|
43
|
+
},
|
|
44
|
+
fork: async (entryId) => {
|
|
45
|
+
const result = await slot.runtime.fork(entryId);
|
|
46
|
+
if (!result.cancelled)
|
|
47
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
48
|
+
return { cancelled: result.cancelled };
|
|
49
|
+
},
|
|
50
|
+
navigateTree: async (targetId, options) => {
|
|
51
|
+
const result = await slot.session.navigateTree(targetId, options);
|
|
52
|
+
if (!result.cancelled)
|
|
53
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
54
|
+
return { cancelled: result.cancelled };
|
|
55
|
+
},
|
|
56
|
+
switchSession: async (sessionPath) => {
|
|
57
|
+
const result = await slot.runtime.switchSession(sessionPath);
|
|
58
|
+
if (!result.cancelled)
|
|
59
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
60
|
+
return { cancelled: result.cancelled };
|
|
61
|
+
},
|
|
62
|
+
reload: () => slot.session.reload(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Resolve the current git branch for a directory. Returns null if not a git repo. */
|
|
66
|
+
function getGitBranch(cwd) {
|
|
67
|
+
try {
|
|
68
|
+
return (execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
69
|
+
cwd,
|
|
70
|
+
encoding: 'utf-8',
|
|
71
|
+
timeout: 2000,
|
|
72
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
73
|
+
}).trim() || null);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export class WsHandler {
|
|
80
|
+
sessionManager;
|
|
81
|
+
folderIndex;
|
|
82
|
+
ws;
|
|
83
|
+
pushNotificationService;
|
|
84
|
+
sessionMetadataStore;
|
|
85
|
+
clientRegistry;
|
|
86
|
+
subscribedSessions = new Set();
|
|
87
|
+
viewedSessionId = null;
|
|
88
|
+
clientId;
|
|
89
|
+
constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry) {
|
|
90
|
+
this.sessionManager = sessionManager;
|
|
91
|
+
this.folderIndex = folderIndex;
|
|
92
|
+
this.ws = ws;
|
|
93
|
+
this.pushNotificationService = pushNotificationService;
|
|
94
|
+
this.sessionMetadataStore = sessionMetadataStore;
|
|
95
|
+
this.clientRegistry = clientRegistry;
|
|
96
|
+
this.clientId = clientId;
|
|
97
|
+
}
|
|
98
|
+
getViewedSessionId() {
|
|
99
|
+
return this.viewedSessionId;
|
|
100
|
+
}
|
|
101
|
+
async handleMessage(raw) {
|
|
102
|
+
let command;
|
|
103
|
+
try {
|
|
104
|
+
command = JSON.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
this.sendResponse('unknown', false, undefined, 'Invalid JSON');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const id = command.id ?? 'unknown';
|
|
111
|
+
try {
|
|
112
|
+
switch (command.type) {
|
|
113
|
+
// ---- Server-level commands ----
|
|
114
|
+
case 'list_folders': {
|
|
115
|
+
const folders = await this.folderIndex.scan();
|
|
116
|
+
// Enrich with active session info
|
|
117
|
+
const activeSessions = this.sessionManager.getAllSessions();
|
|
118
|
+
for (const folder of folders) {
|
|
119
|
+
const folderSessions = activeSessions.filter((s) => s.folderPath === folder.path);
|
|
120
|
+
folder.activeSessionCount = folderSessions.length;
|
|
121
|
+
if (folderSessions.some((s) => s.sessionState.status === 'working')) {
|
|
122
|
+
folder.activeStatus = 'working';
|
|
123
|
+
}
|
|
124
|
+
else if (folderSessions.some((s) => s.sessionState.needsAttention)) {
|
|
125
|
+
folder.activeStatus = 'attention';
|
|
126
|
+
}
|
|
127
|
+
else if (folderSessions.length > 0) {
|
|
128
|
+
folder.activeStatus = 'idle';
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
folder.activeStatus = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.sendResponse(id, true, { folders });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'list_sessions': {
|
|
138
|
+
const sessions = await this.folderIndex.listSessionRecords(command.folderPath);
|
|
139
|
+
const archivedLookup = this.sessionMetadataStore.getArchivedLookup(sessions.map((s) => s.path));
|
|
140
|
+
// Build lookup from session ID to managed session for ownership enrichment
|
|
141
|
+
const activeSessions = this.sessionManager.getAllSessions();
|
|
142
|
+
const slotById = new Map();
|
|
143
|
+
for (const s of activeSessions) {
|
|
144
|
+
slotById.set(s.sessionState.id, s);
|
|
145
|
+
}
|
|
146
|
+
// Enrich each session with ownership, live status, and archived state
|
|
147
|
+
const enriched = sessions
|
|
148
|
+
.map((s) => {
|
|
149
|
+
const sl = slotById.get(s.id);
|
|
150
|
+
return {
|
|
151
|
+
id: s.id,
|
|
152
|
+
name: s.name,
|
|
153
|
+
created: s.created.toISOString(),
|
|
154
|
+
modified: s.modified.toISOString(),
|
|
155
|
+
messageCount: s.messageCount,
|
|
156
|
+
firstMessage: s.firstMessage || undefined,
|
|
157
|
+
archived: archivedLookup.get(s.path) === true,
|
|
158
|
+
isOwnedByMe: sl ? sl.connection?.connectedClientId === this.clientId : false,
|
|
159
|
+
liveStatus: sl ? sl.sessionState.status : null,
|
|
160
|
+
};
|
|
161
|
+
})
|
|
162
|
+
.filter((s) => command.includeArchived || !s.archived);
|
|
163
|
+
this.sendResponse(id, true, { sessions: enriched });
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'open_session': {
|
|
167
|
+
// New session creation
|
|
168
|
+
if (!command.sessionId) {
|
|
169
|
+
const sessionId = await this.sessionManager.openSession(command.folderPath);
|
|
170
|
+
const newSlot = this.sessionManager.getSession(sessionId);
|
|
171
|
+
await this.claimSession(sessionId, newSlot);
|
|
172
|
+
this.viewedSessionId = sessionId;
|
|
173
|
+
this.sendEvent({
|
|
174
|
+
type: 'session_opened',
|
|
175
|
+
sessionId,
|
|
176
|
+
folder: this.buildFolderInfo(newSlot.folderPath),
|
|
177
|
+
});
|
|
178
|
+
WsHandler.broadcastSidebarUpdate(sessionId, newSlot.folderPath, this.sessionManager, this.clientRegistry);
|
|
179
|
+
await this.sendConflictEventIfNeeded(sessionId, newSlot.folderPath);
|
|
180
|
+
this.sendResponse(id, true, { sessionId });
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
// Existing session: reclaim live in-memory runtime if possible, otherwise reopen from disk.
|
|
184
|
+
const requestedSessionId = command.sessionId;
|
|
185
|
+
const existing = this.sessionManager.getSession(requestedSessionId);
|
|
186
|
+
if (existing) {
|
|
187
|
+
if (existing.connection?.connectedClientId && existing.connection.connectedClientId !== this.clientId) {
|
|
188
|
+
const oldHandler = this.clientRegistry.get(existing.connection.connectedClientId);
|
|
189
|
+
if (oldHandler && !command.force) {
|
|
190
|
+
this.sendResponse(id, false, undefined, 'session_owned');
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
this.displaceOwner(requestedSessionId, existing);
|
|
194
|
+
}
|
|
195
|
+
const existingSessionPath = existing.session.sessionFile;
|
|
196
|
+
if (existingSessionPath && this.sessionMetadataStore.isArchived(existingSessionPath)) {
|
|
197
|
+
await this.sessionMetadataStore.setArchived(existingSessionPath, false);
|
|
198
|
+
this.broadcastSessionArchived(requestedSessionId, existing.folderPath, false);
|
|
199
|
+
}
|
|
200
|
+
const restoreMode = await this.syncSessionToClient(requestedSessionId, existing, command.lastCursor);
|
|
201
|
+
WsHandler.broadcastSidebarUpdate(requestedSessionId, existing.folderPath, this.sessionManager, this.clientRegistry);
|
|
202
|
+
this.sendResponse(id, true, { sessionId: requestedSessionId, folderPath: existing.folderPath, restoreMode });
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const sessionFilePath = await this.folderIndex.resolveSessionPath(command.folderPath, requestedSessionId);
|
|
206
|
+
if (!sessionFilePath) {
|
|
207
|
+
this.sendResponse(id, false, undefined, 'session_expired');
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (this.sessionMetadataStore.isArchived(sessionFilePath)) {
|
|
211
|
+
await this.sessionMetadataStore.setArchived(sessionFilePath, false);
|
|
212
|
+
this.broadcastSessionArchived(requestedSessionId, command.folderPath, false);
|
|
213
|
+
}
|
|
214
|
+
const sessionId = await this.sessionManager.openSession(command.folderPath, sessionFilePath);
|
|
215
|
+
const reopenedSlot = this.sessionManager.getSession(sessionId);
|
|
216
|
+
await this.syncSessionToClient(sessionId, reopenedSlot, undefined, 'disk_full_resync');
|
|
217
|
+
WsHandler.broadcastSidebarUpdate(sessionId, reopenedSlot.folderPath, this.sessionManager, this.clientRegistry);
|
|
218
|
+
await this.sendConflictEventIfNeeded(sessionId, reopenedSlot.folderPath);
|
|
219
|
+
this.sendResponse(id, true, { sessionId, folderPath: reopenedSlot.folderPath, restoreMode: 'disk_full_resync' });
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'close_session': {
|
|
223
|
+
const closeSessionId = command.sessionId;
|
|
224
|
+
if (!closeSessionId) {
|
|
225
|
+
this.sendResponse(id, false, undefined, 'sessionId is required');
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
// Resolve pending extension UI responses so tools don't hang
|
|
229
|
+
const closingSlot = this.sessionManager.getSession(closeSessionId);
|
|
230
|
+
if (closingSlot) {
|
|
231
|
+
resolveAllSlotPendingUi(closingSlot);
|
|
232
|
+
}
|
|
233
|
+
await this.sessionManager.closeSession(closeSessionId);
|
|
234
|
+
this.subscribedSessions.delete(closeSessionId);
|
|
235
|
+
if (this.viewedSessionId === closeSessionId) {
|
|
236
|
+
this.viewedSessionId = null;
|
|
237
|
+
}
|
|
238
|
+
this.sendEvent({
|
|
239
|
+
type: 'session_closed',
|
|
240
|
+
sessionId: closeSessionId,
|
|
241
|
+
});
|
|
242
|
+
this.sendResponse(id, true);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'delete_session': {
|
|
246
|
+
const deleteSessionId = command.sessionId;
|
|
247
|
+
const deleteFolderPath = command.folderPath;
|
|
248
|
+
if (!deleteSessionId || !deleteFolderPath) {
|
|
249
|
+
this.sendResponse(id, false, undefined, 'sessionId and folderPath are required');
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
const deleteSlot = this.sessionManager.getSession(deleteSessionId);
|
|
253
|
+
const deleteSessionPath = deleteSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(deleteFolderPath, deleteSessionId));
|
|
254
|
+
// If the session is active in memory, close it first
|
|
255
|
+
if (deleteSlot) {
|
|
256
|
+
// Notify the owning client if it's a different client
|
|
257
|
+
if (deleteSlot.connection?.connectedClientId && deleteSlot.connection.connectedClientId !== this.clientId) {
|
|
258
|
+
const ownerHandler = this.clientRegistry.get(deleteSlot.connection.connectedClientId);
|
|
259
|
+
if (ownerHandler) {
|
|
260
|
+
ownerHandler.sendKilledEvent(deleteSessionId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
resolveAllSlotPendingUi(deleteSlot);
|
|
264
|
+
await this.sessionManager.closeSession(deleteSessionId);
|
|
265
|
+
}
|
|
266
|
+
// Delete the file from disk
|
|
267
|
+
const deleted = await this.folderIndex.deleteSession(deleteFolderPath, deleteSessionId);
|
|
268
|
+
if (!deleted) {
|
|
269
|
+
this.sendResponse(id, false, undefined, `Session not found: ${deleteSessionId}`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (deleteSessionPath) {
|
|
273
|
+
await this.sessionMetadataStore.delete(deleteSessionPath);
|
|
274
|
+
}
|
|
275
|
+
// Broadcast deletion to all clients so sidebar lists update
|
|
276
|
+
const deleteEvent = {
|
|
277
|
+
type: 'session_deleted',
|
|
278
|
+
sessionId: deleteSessionId,
|
|
279
|
+
folderPath: deleteFolderPath,
|
|
280
|
+
};
|
|
281
|
+
for (const [, handler] of this.clientRegistry) {
|
|
282
|
+
handler.sendToClient(deleteEvent);
|
|
283
|
+
}
|
|
284
|
+
this.subscribedSessions.delete(deleteSessionId);
|
|
285
|
+
if (this.viewedSessionId === deleteSessionId) {
|
|
286
|
+
this.viewedSessionId = null;
|
|
287
|
+
}
|
|
288
|
+
this.sendResponse(id, true);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case 'archive_session': {
|
|
292
|
+
const archiveSessionId = command.sessionId;
|
|
293
|
+
const archiveFolderPath = command.folderPath;
|
|
294
|
+
if (!archiveSessionId || !archiveFolderPath) {
|
|
295
|
+
this.sendResponse(id, false, undefined, 'sessionId and folderPath are required');
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const archiveSlot = this.sessionManager.getSession(archiveSessionId);
|
|
299
|
+
const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
|
|
300
|
+
if (!archiveSessionPath) {
|
|
301
|
+
this.sendResponse(id, false, undefined, `Session not found: ${archiveSessionId}`);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
|
|
305
|
+
this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
|
|
306
|
+
this.sendResponse(id, true, { archived: command.archived });
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'rename_session': {
|
|
310
|
+
const renameSessionId = command.sessionId;
|
|
311
|
+
const renameFolderPath = command.folderPath;
|
|
312
|
+
const renameName = command.name.trim();
|
|
313
|
+
if (!renameSessionId || !renameFolderPath) {
|
|
314
|
+
this.sendResponse(id, false, undefined, 'sessionId and folderPath are required');
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (!renameName) {
|
|
318
|
+
this.sendResponse(id, false, undefined, 'name is required');
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
const renameSlot = this.sessionManager.getSession(renameSessionId);
|
|
322
|
+
if (renameSlot) {
|
|
323
|
+
renameSlot.session.setSessionName(renameName);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const renamed = await this.folderIndex.renameSession(renameFolderPath, renameSessionId, renameName);
|
|
327
|
+
if (!renamed) {
|
|
328
|
+
this.sendResponse(id, false, undefined, `Session not found: ${renameSessionId}`);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const renameEvent = {
|
|
333
|
+
type: 'session_renamed',
|
|
334
|
+
sessionId: renameSessionId,
|
|
335
|
+
folderPath: renameFolderPath,
|
|
336
|
+
name: renameName,
|
|
337
|
+
};
|
|
338
|
+
for (const [, handler] of this.clientRegistry) {
|
|
339
|
+
handler.sendToClient(renameEvent);
|
|
340
|
+
}
|
|
341
|
+
this.sendResponse(id, true, { name: renameName });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'takeover_folder': {
|
|
345
|
+
const killedCount = await killExternalPiProcesses(command.folderPath);
|
|
346
|
+
const takeoverSessionId = await this.sessionManager.openSession(command.folderPath);
|
|
347
|
+
const takeoverSlot = this.sessionManager.getSession(takeoverSessionId);
|
|
348
|
+
await this.claimSession(takeoverSessionId, takeoverSlot);
|
|
349
|
+
this.viewedSessionId = takeoverSessionId;
|
|
350
|
+
this.sendEvent({
|
|
351
|
+
type: 'session_opened',
|
|
352
|
+
sessionId: takeoverSessionId,
|
|
353
|
+
folder: {
|
|
354
|
+
path: takeoverSlot.folderPath,
|
|
355
|
+
name: takeoverSlot.folderPath.split('/').pop() ?? takeoverSlot.folderPath,
|
|
356
|
+
activeSessionCount: 1,
|
|
357
|
+
externalProcessCount: 0,
|
|
358
|
+
activeStatus: 'idle',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
WsHandler.broadcastSidebarUpdate(takeoverSessionId, takeoverSlot.folderPath, this.sessionManager, this.clientRegistry);
|
|
362
|
+
this.sendResponse(id, true, { sessionId: takeoverSessionId, killedProcesses: killedCount });
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
// ---- Extension UI ----
|
|
366
|
+
case 'extension_ui_response': {
|
|
367
|
+
const uiSlot = command.sessionId ? this.sessionManager.getSession(command.sessionId) : undefined;
|
|
368
|
+
if (uiSlot) {
|
|
369
|
+
let value;
|
|
370
|
+
if (command.cancelled) {
|
|
371
|
+
value = undefined;
|
|
372
|
+
}
|
|
373
|
+
else if (typeof command.confirmed === 'boolean') {
|
|
374
|
+
value = command.confirmed;
|
|
375
|
+
}
|
|
376
|
+
else if (command.value !== undefined) {
|
|
377
|
+
value = command.value;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
value = undefined;
|
|
381
|
+
}
|
|
382
|
+
resolveSlotPendingUi(uiSlot, command.requestId, value);
|
|
383
|
+
}
|
|
384
|
+
this.sendResponse(id, true);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
// ---- Multi-session & push commands ----
|
|
388
|
+
case 'view_session': {
|
|
389
|
+
this.viewedSessionId = command.sessionId;
|
|
390
|
+
const viewedSlot = this.sessionManager.getSession(command.sessionId);
|
|
391
|
+
if (viewedSlot) {
|
|
392
|
+
viewedSlot.sessionState.needsAttention = false;
|
|
393
|
+
// Always send current panel state so the client syncs after switching sessions
|
|
394
|
+
this.sendEvent({ type: 'panel_update', sessionId: command.sessionId, cards: getMergedPanelCards(viewedSlot.sessionState.panelState) });
|
|
395
|
+
}
|
|
396
|
+
this.sendResponse(id, true);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case 'register_push': {
|
|
400
|
+
const sub = command.subscription;
|
|
401
|
+
if (!sub ||
|
|
402
|
+
typeof sub !== 'object' ||
|
|
403
|
+
typeof sub.endpoint !== 'string' ||
|
|
404
|
+
!sub.endpoint ||
|
|
405
|
+
!sub.keys ||
|
|
406
|
+
typeof sub.keys !== 'object' ||
|
|
407
|
+
typeof sub.keys.p256dh !== 'string' ||
|
|
408
|
+
!sub.keys.p256dh ||
|
|
409
|
+
typeof sub.keys.auth !== 'string' ||
|
|
410
|
+
!sub.keys.auth) {
|
|
411
|
+
this.sendResponse(id, false, undefined, 'Invalid push subscription: endpoint, keys.p256dh, and keys.auth are required');
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
await this.pushNotificationService.addSubscription(sub);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
this.sendResponse(id, false, undefined, 'Failed to save push subscription');
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
this.sendResponse(id, true);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case 'unregister_push': {
|
|
425
|
+
await this.pushNotificationService.removeSubscription(command.endpoint);
|
|
426
|
+
this.sendResponse(id, true);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'kill_conflicting_sessions': {
|
|
430
|
+
for (const targetSessionId of command.sessionIds) {
|
|
431
|
+
const targetSlot = this.sessionManager.getSession(targetSessionId);
|
|
432
|
+
if (!targetSlot)
|
|
433
|
+
continue;
|
|
434
|
+
// Notify the owning client if still connected
|
|
435
|
+
if (targetSlot.connection?.connectedClientId) {
|
|
436
|
+
const ownerHandler = this.clientRegistry.get(targetSlot.connection.connectedClientId);
|
|
437
|
+
if (ownerHandler) {
|
|
438
|
+
ownerHandler.sendKilledEvent(targetSessionId);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await this.sessionManager.closeSession(targetSessionId);
|
|
442
|
+
}
|
|
443
|
+
this.sendResponse(id, true);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
case 'kill_conflicting_processes': {
|
|
447
|
+
const killManaged = this.sessionManager.getSession(command.sessionId);
|
|
448
|
+
if (!killManaged) {
|
|
449
|
+
this.sendResponse(id, false, undefined, `Session not found: ${command.sessionId}`);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
const killedProcessCount = await killExternalPiProcesses(killManaged.folderPath, command.pids);
|
|
453
|
+
this.sendResponse(id, true, { killedCount: killedProcessCount });
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
// ---- Session control commands ----
|
|
457
|
+
case 'prompt':
|
|
458
|
+
case 'steer':
|
|
459
|
+
case 'follow_up':
|
|
460
|
+
case 'abort':
|
|
461
|
+
case 'set_model':
|
|
462
|
+
case 'cycle_model':
|
|
463
|
+
case 'get_available_models':
|
|
464
|
+
case 'set_thinking_level':
|
|
465
|
+
case 'cycle_thinking_level':
|
|
466
|
+
case 'compact':
|
|
467
|
+
case 'set_auto_compaction':
|
|
468
|
+
case 'get_state':
|
|
469
|
+
case 'get_messages':
|
|
470
|
+
case 'new_session':
|
|
471
|
+
case 'get_session_stats':
|
|
472
|
+
case 'get_session_meta':
|
|
473
|
+
case 'get_commands':
|
|
474
|
+
case 'complete_args':
|
|
475
|
+
case 'set_session_name':
|
|
476
|
+
case 'dequeue_steering': {
|
|
477
|
+
await this.handleSessionCommand(command, id);
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
default: {
|
|
481
|
+
this.sendResponse(id, false, undefined, `Unknown command type: ${command.type}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
487
|
+
console.error(`[WsHandler] Error handling command ${command.type}:`, message);
|
|
488
|
+
this.sendResponse(id, false, undefined, message);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async handleSessionCommand(command, id) {
|
|
492
|
+
const sessionId = command.sessionId;
|
|
493
|
+
if (!sessionId) {
|
|
494
|
+
this.sendResponse(id, false, undefined, 'sessionId is required');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const slot = this.sessionManager.getSession(sessionId);
|
|
498
|
+
if (!slot) {
|
|
499
|
+
this.sendResponse(id, false, undefined, `Session not found: ${sessionId}`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const session = slot.session;
|
|
503
|
+
switch (command.type) {
|
|
504
|
+
case 'prompt': {
|
|
505
|
+
// Intercept pimote built-in slash commands
|
|
506
|
+
const trimmed = command.message.trim();
|
|
507
|
+
if (trimmed === '/new') {
|
|
508
|
+
const result = await slot.runtime.newSession();
|
|
509
|
+
if (!result.cancelled)
|
|
510
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
511
|
+
this.sendResponse(id, true, { success: !result.cancelled });
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
if (trimmed === '/reload') {
|
|
515
|
+
session.reload();
|
|
516
|
+
this.sendResponse(id, true);
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
session.prompt(command.message, { images: parseDataUrlImages(command.images) }).catch((err) => {
|
|
520
|
+
console.error(`[WsHandler] prompt error:`, err);
|
|
521
|
+
});
|
|
522
|
+
this.sendResponse(id, true);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'steer': {
|
|
526
|
+
session.steer(command.message).catch((err) => {
|
|
527
|
+
console.error(`[WsHandler] steer error:`, err);
|
|
528
|
+
});
|
|
529
|
+
this.sendResponse(id, true);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case 'dequeue_steering': {
|
|
533
|
+
const result = session.clearQueue();
|
|
534
|
+
this.sendResponse(id, true, { steering: result.steering, followUp: result.followUp });
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case 'follow_up': {
|
|
538
|
+
session.followUp(command.message).catch((err) => {
|
|
539
|
+
console.error(`[WsHandler] followUp error:`, err);
|
|
540
|
+
});
|
|
541
|
+
this.sendResponse(id, true);
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case 'abort': {
|
|
545
|
+
// Resolve pending UI responses first so stuck dialogs unblock
|
|
546
|
+
resolveAllSlotPendingUi(slot);
|
|
547
|
+
await session.abort();
|
|
548
|
+
this.sendResponse(id, true);
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
case 'set_model': {
|
|
552
|
+
const models = slot.session.modelRegistry.getAvailable();
|
|
553
|
+
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
|
|
554
|
+
if (!model) {
|
|
555
|
+
this.sendResponse(id, false, undefined, `Model not found: ${command.provider}/${command.modelId}`);
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
await session.setModel(model);
|
|
559
|
+
this.sendResponse(id, true);
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
case 'cycle_model': {
|
|
563
|
+
const result = await session.cycleModel();
|
|
564
|
+
if (result) {
|
|
565
|
+
this.sendResponse(id, true, {
|
|
566
|
+
model: { provider: result.model.provider, id: result.model.id, name: result.model.name },
|
|
567
|
+
thinkingLevel: result.thinkingLevel,
|
|
568
|
+
isScoped: result.isScoped,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
this.sendResponse(id, true, null);
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case 'get_available_models': {
|
|
577
|
+
const models = slot.session.modelRegistry.getAvailable();
|
|
578
|
+
const mapped = models.map((m) => ({
|
|
579
|
+
provider: m.provider,
|
|
580
|
+
id: m.id,
|
|
581
|
+
name: m.name,
|
|
582
|
+
}));
|
|
583
|
+
this.sendResponse(id, true, { models: mapped });
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case 'set_thinking_level': {
|
|
587
|
+
session.setThinkingLevel(command.level);
|
|
588
|
+
this.sendResponse(id, true);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case 'cycle_thinking_level': {
|
|
592
|
+
const level = session.cycleThinkingLevel();
|
|
593
|
+
this.sendResponse(id, true, { level });
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'compact': {
|
|
597
|
+
const result = await session.compact(command.customInstructions);
|
|
598
|
+
this.sendResponse(id, true, { result });
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
case 'set_auto_compaction': {
|
|
602
|
+
session.setAutoCompactionEnabled(command.enabled);
|
|
603
|
+
this.sendResponse(id, true);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case 'get_state': {
|
|
607
|
+
const model = session.model;
|
|
608
|
+
const state = {
|
|
609
|
+
model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
|
|
610
|
+
thinkingLevel: session.thinkingLevel,
|
|
611
|
+
isStreaming: session.isStreaming,
|
|
612
|
+
isCompacting: session.isCompacting,
|
|
613
|
+
sessionFile: session.sessionFile,
|
|
614
|
+
sessionId: session.sessionId,
|
|
615
|
+
sessionName: session.sessionName,
|
|
616
|
+
autoCompactionEnabled: session.autoCompactionEnabled,
|
|
617
|
+
messageCount: session.messages.length,
|
|
618
|
+
};
|
|
619
|
+
this.sendResponse(id, true, { state });
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
case 'get_messages': {
|
|
623
|
+
const messages = mapAgentMessages(session.messages);
|
|
624
|
+
this.sendResponse(id, true, { messages });
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case 'new_session': {
|
|
628
|
+
const result = await slot.runtime.newSession();
|
|
629
|
+
if (!result.cancelled)
|
|
630
|
+
await slot.connection?.onSessionReset?.(slot);
|
|
631
|
+
this.sendResponse(id, true, { success: !result.cancelled });
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case 'get_session_stats': {
|
|
635
|
+
const stats = session.getSessionStats();
|
|
636
|
+
this.sendResponse(id, true, { stats });
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
case 'get_session_meta': {
|
|
640
|
+
const contextUsage = session.getContextUsage();
|
|
641
|
+
const meta = {
|
|
642
|
+
gitBranch: getGitBranch(slot.folderPath),
|
|
643
|
+
contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow } : null,
|
|
644
|
+
};
|
|
645
|
+
this.sendResponse(id, true, { meta });
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case 'get_commands': {
|
|
649
|
+
const commands = [];
|
|
650
|
+
// Skills
|
|
651
|
+
const { skills } = session.resourceLoader.getSkills();
|
|
652
|
+
for (const skill of skills) {
|
|
653
|
+
commands.push({
|
|
654
|
+
name: `skill:${skill.name}`,
|
|
655
|
+
description: skill.description,
|
|
656
|
+
hasArgCompletions: false,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// Prompt templates
|
|
660
|
+
for (const template of session.promptTemplates) {
|
|
661
|
+
commands.push({
|
|
662
|
+
name: template.name,
|
|
663
|
+
description: template.description,
|
|
664
|
+
hasArgCompletions: false,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// Extension commands
|
|
668
|
+
const extensionCommands = session.extensionRunner?.getRegisteredCommands() ?? [];
|
|
669
|
+
for (const cmd of extensionCommands) {
|
|
670
|
+
commands.push({
|
|
671
|
+
name: cmd.name,
|
|
672
|
+
description: cmd.description ?? '',
|
|
673
|
+
hasArgCompletions: !!cmd.getArgumentCompletions,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
// Pimote built-in commands
|
|
677
|
+
commands.push({ name: 'new', description: 'Start a new session', hasArgCompletions: false }, { name: 'reload', description: 'Reload extensions and skills', hasArgCompletions: false });
|
|
678
|
+
this.sendResponse(id, true, { commands });
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
case 'complete_args': {
|
|
682
|
+
const runner = session.extensionRunner;
|
|
683
|
+
if (!runner) {
|
|
684
|
+
this.sendResponse(id, true, { items: null });
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
const cmd = runner.getCommand(command.commandName);
|
|
688
|
+
if (!cmd || !cmd.getArgumentCompletions) {
|
|
689
|
+
this.sendResponse(id, true, { items: null });
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
const items = await cmd.getArgumentCompletions(command.prefix);
|
|
693
|
+
this.sendResponse(id, true, { items: items ?? null });
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
case 'set_session_name': {
|
|
697
|
+
session.setSessionName(command.name);
|
|
698
|
+
this.sendResponse(id, true);
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/** Notify the old owner that they've been displaced from a session.
|
|
704
|
+
* No-op if the session is unowned or owned by this client. */
|
|
705
|
+
displaceOwner(sessionId, slot) {
|
|
706
|
+
if (slot.connection?.connectedClientId && slot.connection.connectedClientId !== this.clientId) {
|
|
707
|
+
const oldHandler = this.clientRegistry.get(slot.connection.connectedClientId);
|
|
708
|
+
if (oldHandler) {
|
|
709
|
+
oldHandler.sendDisplacedEvent(sessionId);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/** Bind a slot to this client — sets ownership, WebSocket routing,
|
|
714
|
+
* and subscribes to events. Extensions are bound once on first claim. */
|
|
715
|
+
async claimSession(sessionId, slot) {
|
|
716
|
+
const connection = {
|
|
717
|
+
ws: this.ws,
|
|
718
|
+
connectedClientId: this.clientId,
|
|
719
|
+
onSessionReset: (s) => this.handleSessionReset(s),
|
|
720
|
+
};
|
|
721
|
+
slot.connection = connection;
|
|
722
|
+
slot.sessionState.lastActivity = Date.now();
|
|
723
|
+
this.subscribedSessions.add(sessionId);
|
|
724
|
+
// Bind extensions when needed. The bridge holds a direct reference to this
|
|
725
|
+
// ManagedSlot — on reconnect we skip rebinding, but on session reset
|
|
726
|
+
// we must rebind so the bridge points at the new session state.
|
|
727
|
+
if (!slot.sessionState.extensionsBound) {
|
|
728
|
+
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService);
|
|
729
|
+
const commandContextActions = createCommandContextActions(slot);
|
|
730
|
+
await slot.session.bindExtensions({ uiContext, commandContextActions });
|
|
731
|
+
slot.sessionState.extensionsBound = true;
|
|
732
|
+
}
|
|
733
|
+
// Re-deliver any pending UI requests to the new client (recovers lost dialogs)
|
|
734
|
+
replaySlotPendingUiRequests(slot);
|
|
735
|
+
}
|
|
736
|
+
/** Handle a session reset (newSession, fork, switchSession).
|
|
737
|
+
* Called via slot.connection.onSessionReset after the runtime has replaced the session. */
|
|
738
|
+
async handleSessionReset(slot) {
|
|
739
|
+
const newSessionId = slot.runtime.session.sessionId;
|
|
740
|
+
const oldSessionId = slot.sessionState.id;
|
|
741
|
+
// navigateTree stays in the same file — same session ID, just resync
|
|
742
|
+
if (newSessionId === oldSessionId) {
|
|
743
|
+
this.sendFullResyncForSession(oldSessionId, slot);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
// Session ID changed — rebuild session state in-place on the same slot.
|
|
747
|
+
const folderPath = slot.folderPath;
|
|
748
|
+
// Rebuild session state (tears down old, creates new from runtime.session)
|
|
749
|
+
this.sessionManager.rebuildSessionState(slot);
|
|
750
|
+
// Re-key the session map
|
|
751
|
+
this.sessionManager.reKeySession(slot, oldSessionId, newSessionId);
|
|
752
|
+
// Update handler bookkeeping
|
|
753
|
+
this.subscribedSessions.delete(oldSessionId);
|
|
754
|
+
this.subscribedSessions.add(newSessionId);
|
|
755
|
+
if (this.viewedSessionId === oldSessionId) {
|
|
756
|
+
this.viewedSessionId = newSessionId;
|
|
757
|
+
}
|
|
758
|
+
// Rebind extension UI bridge (new session state for dialog routing)
|
|
759
|
+
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService);
|
|
760
|
+
const commandContextActions = createCommandContextActions(slot);
|
|
761
|
+
await slot.session.bindExtensions({ uiContext, commandContextActions });
|
|
762
|
+
slot.sessionState.extensionsBound = true;
|
|
763
|
+
// Notify owning client: session replaced (client re-keys in place)
|
|
764
|
+
this.sendEvent({
|
|
765
|
+
type: 'session_replaced',
|
|
766
|
+
oldSessionId,
|
|
767
|
+
newSessionId,
|
|
768
|
+
folder: {
|
|
769
|
+
path: folderPath,
|
|
770
|
+
name: folderPath.split('/').pop() ?? folderPath,
|
|
771
|
+
activeSessionCount: this.sessionManager.getAllSessions().filter((s) => s.folderPath === folderPath).length,
|
|
772
|
+
externalProcessCount: 0,
|
|
773
|
+
activeStatus: 'idle',
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
// Broadcast sidebar updates for both old (now inactive) and new (now active)
|
|
777
|
+
WsHandler.broadcastSidebarUpdate(oldSessionId, folderPath, this.sessionManager, this.clientRegistry);
|
|
778
|
+
WsHandler.broadcastSidebarUpdate(newSessionId, folderPath, this.sessionManager, this.clientRegistry);
|
|
779
|
+
}
|
|
780
|
+
buildFolderInfo(folderPath) {
|
|
781
|
+
return {
|
|
782
|
+
path: folderPath,
|
|
783
|
+
name: folderPath.split('/').pop() ?? folderPath,
|
|
784
|
+
activeSessionCount: 1,
|
|
785
|
+
externalProcessCount: 0,
|
|
786
|
+
activeStatus: 'idle',
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
async sendConflictEventIfNeeded(sessionId, folderPath) {
|
|
790
|
+
const openConflictPids = await findExternalPiProcesses(folderPath);
|
|
791
|
+
const allSessions = this.sessionManager.getAllSessions();
|
|
792
|
+
const remoteSessions = allSessions
|
|
793
|
+
.filter((s) => s.folderPath === folderPath && s.connection?.connectedClientId !== null && s.connection?.connectedClientId !== this.clientId && s.sessionState.id !== sessionId)
|
|
794
|
+
.map((s) => ({ sessionId: s.sessionState.id, status: s.sessionState.status }));
|
|
795
|
+
if (openConflictPids.length > 0 || remoteSessions.length > 0) {
|
|
796
|
+
this.sendEvent({
|
|
797
|
+
type: 'session_conflict',
|
|
798
|
+
sessionId,
|
|
799
|
+
processes: openConflictPids.map((pid) => ({ pid, command: 'pi' })),
|
|
800
|
+
remoteSessions,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async syncSessionToClient(sessionId, slot, lastCursor, noCursorRestoreMode = 'full_resync_no_cursor') {
|
|
805
|
+
let replayResult = null;
|
|
806
|
+
let cursorBeforeClaim = null;
|
|
807
|
+
let restoreMode;
|
|
808
|
+
if (lastCursor !== undefined) {
|
|
809
|
+
replayResult = slot.sessionState.eventBuffer.replay(lastCursor);
|
|
810
|
+
if (replayResult !== null) {
|
|
811
|
+
restoreMode = 'incremental_replay';
|
|
812
|
+
this.sendEvent({ type: 'session_restore', sessionId, mode: restoreMode, status: 'started' });
|
|
813
|
+
this.sendEvent({
|
|
814
|
+
type: 'buffered_events',
|
|
815
|
+
sessionId,
|
|
816
|
+
events: replayResult,
|
|
817
|
+
});
|
|
818
|
+
this.sendEvent({
|
|
819
|
+
type: 'connection_restored',
|
|
820
|
+
sessionId,
|
|
821
|
+
});
|
|
822
|
+
cursorBeforeClaim = slot.sessionState.eventBuffer.currentCursor;
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
restoreMode = 'full_resync_cursor_stale';
|
|
826
|
+
this.sendEvent({ type: 'session_restore', sessionId, mode: restoreMode, status: 'started' });
|
|
827
|
+
this.sendFullResyncForSession(sessionId, slot);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
restoreMode = noCursorRestoreMode;
|
|
832
|
+
this.sendEvent({ type: 'session_restore', sessionId, mode: restoreMode, status: 'started' });
|
|
833
|
+
this.sendFullResyncForSession(sessionId, slot);
|
|
834
|
+
}
|
|
835
|
+
await this.claimSession(sessionId, slot);
|
|
836
|
+
if (replayResult !== null && cursorBeforeClaim !== null) {
|
|
837
|
+
const catchUp = slot.sessionState.eventBuffer.replay(cursorBeforeClaim);
|
|
838
|
+
if (catchUp && catchUp.length > 0) {
|
|
839
|
+
this.sendEvent({
|
|
840
|
+
type: 'buffered_events',
|
|
841
|
+
sessionId,
|
|
842
|
+
events: catchUp,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (replayResult !== null) {
|
|
847
|
+
// Always send panel state after reconnect so the client clears stale cards
|
|
848
|
+
this.sendEvent({ type: 'panel_update', sessionId, cards: getMergedPanelCards(slot.sessionState.panelState) });
|
|
849
|
+
}
|
|
850
|
+
this.sendEvent({ type: 'session_restore', sessionId, mode: restoreMode, status: 'completed' });
|
|
851
|
+
return restoreMode;
|
|
852
|
+
}
|
|
853
|
+
/** Close this handler's WebSocket connection. */
|
|
854
|
+
closeWebSocket() {
|
|
855
|
+
try {
|
|
856
|
+
this.ws.close();
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Already closed or errored — ignore
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** Send a session_closed event with reason 'displaced' to this client's WebSocket.
|
|
863
|
+
* Also removes the session from this handler's subscribedSessions so that
|
|
864
|
+
* cleanup() won't stomp the new owner's bindings when this handler closes. */
|
|
865
|
+
sendDisplacedEvent(sessionId) {
|
|
866
|
+
this.subscribedSessions.delete(sessionId);
|
|
867
|
+
this.sendEvent({
|
|
868
|
+
type: 'session_closed',
|
|
869
|
+
sessionId,
|
|
870
|
+
reason: 'displaced',
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
/** Send a session_closed event with reason 'killed' to this client's WebSocket.
|
|
874
|
+
* Also removes the session from this handler's subscribedSessions so that
|
|
875
|
+
* cleanup() won't stomp stale entries. */
|
|
876
|
+
sendKilledEvent(sessionId) {
|
|
877
|
+
this.subscribedSessions.delete(sessionId);
|
|
878
|
+
this.sendEvent({
|
|
879
|
+
type: 'session_closed',
|
|
880
|
+
sessionId,
|
|
881
|
+
reason: 'killed',
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
broadcastSessionArchived(sessionId, folderPath, archived) {
|
|
885
|
+
const event = {
|
|
886
|
+
type: 'session_archived',
|
|
887
|
+
sessionId,
|
|
888
|
+
folderPath,
|
|
889
|
+
archived,
|
|
890
|
+
};
|
|
891
|
+
for (const [, handler] of this.clientRegistry) {
|
|
892
|
+
handler.sendToClient(event);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
sendResponse(id, success, data, error) {
|
|
896
|
+
const response = { id, success };
|
|
897
|
+
if (data !== undefined)
|
|
898
|
+
response.data = data;
|
|
899
|
+
if (error !== undefined)
|
|
900
|
+
response.error = error;
|
|
901
|
+
try {
|
|
902
|
+
this.ws.send(JSON.stringify(response));
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
console.error('[WsHandler] Failed to send response:', err);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
sendEvent(event) {
|
|
909
|
+
try {
|
|
910
|
+
this.ws.send(JSON.stringify(event));
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
console.error('[WsHandler] Failed to send event:', err);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/** Send a full_resync event to the client for the given managed session.
|
|
917
|
+
* Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
|
|
918
|
+
sendFullResyncForSession(pimoteSessionId, slot) {
|
|
919
|
+
const session = slot.session;
|
|
920
|
+
const model = session.model;
|
|
921
|
+
const state = {
|
|
922
|
+
model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
|
|
923
|
+
thinkingLevel: session.thinkingLevel,
|
|
924
|
+
isStreaming: session.isStreaming,
|
|
925
|
+
isCompacting: session.isCompacting,
|
|
926
|
+
sessionFile: session.sessionFile,
|
|
927
|
+
sessionId: session.sessionId,
|
|
928
|
+
sessionName: session.sessionName,
|
|
929
|
+
autoCompactionEnabled: session.autoCompactionEnabled,
|
|
930
|
+
messageCount: session.messages.length,
|
|
931
|
+
};
|
|
932
|
+
const messages = mapAgentMessages(session.messages);
|
|
933
|
+
const fullResyncEvent = {
|
|
934
|
+
type: 'full_resync',
|
|
935
|
+
sessionId: pimoteSessionId,
|
|
936
|
+
state,
|
|
937
|
+
messages,
|
|
938
|
+
};
|
|
939
|
+
this.sendEvent(fullResyncEvent);
|
|
940
|
+
// Always send panel snapshot so the client clears stale cards after reconnect
|
|
941
|
+
this.sendEvent({ type: 'panel_update', sessionId: pimoteSessionId, cards: getMergedPanelCards(slot.sessionState.panelState) });
|
|
942
|
+
}
|
|
943
|
+
/** Send an event to this client (public for broadcast use). */
|
|
944
|
+
sendToClient(event) {
|
|
945
|
+
this.sendEvent(event);
|
|
946
|
+
}
|
|
947
|
+
/** Broadcast a session_state_changed event to ALL connected clients. */
|
|
948
|
+
static broadcastSidebarUpdate(sessionId, folderPath, sessionManager, clientRegistry) {
|
|
949
|
+
const slot = sessionManager.getSession(sessionId);
|
|
950
|
+
// Compute folder aggregates (same logic as list_folders handler)
|
|
951
|
+
const folderSessions = sessionManager.getAllSessions().filter((s) => s.folderPath === folderPath);
|
|
952
|
+
const folderActiveSessionCount = folderSessions.length;
|
|
953
|
+
let folderActiveStatus = null;
|
|
954
|
+
if (folderSessions.some((s) => s.sessionState.status === 'working')) {
|
|
955
|
+
folderActiveStatus = 'working';
|
|
956
|
+
}
|
|
957
|
+
else if (folderSessions.some((s) => s.sessionState.needsAttention)) {
|
|
958
|
+
folderActiveStatus = 'attention';
|
|
959
|
+
}
|
|
960
|
+
else if (folderSessions.length > 0) {
|
|
961
|
+
folderActiveStatus = 'idle';
|
|
962
|
+
}
|
|
963
|
+
const event = {
|
|
964
|
+
type: 'session_state_changed',
|
|
965
|
+
sessionId,
|
|
966
|
+
folderPath,
|
|
967
|
+
liveStatus: slot ? slot.sessionState.status : null,
|
|
968
|
+
connectedClientId: slot ? (slot.connection?.connectedClientId ?? null) : null,
|
|
969
|
+
folderActiveSessionCount,
|
|
970
|
+
folderActiveStatus,
|
|
971
|
+
};
|
|
972
|
+
for (const [, handler] of clientRegistry) {
|
|
973
|
+
handler.sendToClient(event);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
cleanup() {
|
|
977
|
+
for (const sid of this.subscribedSessions) {
|
|
978
|
+
const slot = this.sessionManager.getSession(sid);
|
|
979
|
+
if (slot) {
|
|
980
|
+
slot.connection = null;
|
|
981
|
+
slot.sessionState.lastActivity = Date.now();
|
|
982
|
+
// Note: pending UI responses are NOT resolved here — they survive
|
|
983
|
+
// for replay on reconnect. They are resolved on session close or abort.
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
this.subscribedSessions.clear();
|
|
987
|
+
this.viewedSessionId = null;
|
|
988
|
+
}
|
|
989
|
+
}
|