@pimote/pimote 0.1.1 → 0.3.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 +46 -17
- package/client/build/_app/immutable/assets/0.C7loWTOC.css +2 -0
- package/client/build/_app/immutable/assets/2.D9fiCd8W.css +1 -0
- package/client/build/_app/immutable/chunks/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
- package/client/build/_app/immutable/chunks/BNqgidwO.js +5 -0
- package/client/build/_app/immutable/chunks/D26i4pYm.js +1 -0
- package/client/build/_app/immutable/chunks/D_Fpgknp.js +1 -0
- package/client/build/_app/immutable/chunks/DoVhjU85.js +1 -0
- package/client/build/_app/immutable/chunks/DzqbY2XU.js +1 -0
- package/client/build/_app/immutable/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
- package/client/build/_app/immutable/entry/app.DO-zgzyy.js +2 -0
- package/client/build/_app/immutable/entry/start.BZlrOH0-.js +1 -0
- package/client/build/_app/immutable/nodes/0.BEh4bPGQ.js +10 -0
- package/client/build/_app/immutable/nodes/1.B2l9JGRO.js +1 -0
- package/client/build/_app/immutable/nodes/2.ph9M0S1U.js +54 -0
- package/client/build/_app/version.json +1 -1
- package/client/build/index.html +8 -8
- package/package.json +9 -5
- package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
- package/server/dist/auto-drain-on-abort.js +49 -0
- package/server/dist/config.js +21 -0
- package/server/dist/extension-ui-bridge.js +14 -1
- package/server/dist/folder-index.js +8 -4
- package/server/dist/git-branch.js +32 -0
- package/server/dist/index.js +31 -1
- package/server/dist/message-mapper.js +99 -4
- package/server/dist/server.js +5 -2
- package/server/dist/session-manager.js +99 -6
- package/server/dist/voice/fsm/actions.js +6 -0
- package/server/dist/voice/fsm/events.js +7 -0
- package/server/dist/voice/fsm/reducer.js +74 -0
- package/server/dist/voice/fsm/reducers/lifecycle.js +146 -0
- package/server/dist/voice/fsm/reducers/streaming.js +220 -0
- package/server/dist/voice/fsm/reducers/walkback.js +73 -0
- package/server/dist/voice/fsm/state.js +21 -0
- package/server/dist/voice/fsm/text-extractor.js +128 -0
- package/server/dist/voice/index.js +319 -0
- package/server/dist/voice/interpreter-prompt.js +115 -0
- package/server/dist/voice/speechmux-client.js +153 -0
- package/server/dist/voice/state-machine.js +7 -0
- package/server/dist/voice/wait-for-idle.js +67 -0
- package/server/dist/voice/walk-back.js +198 -0
- package/server/dist/voice-orchestrator-boot.js +90 -0
- package/server/dist/voice-orchestrator.js +91 -0
- package/server/dist/ws-handler.js +340 -36
- package/shared/dist/index.d.ts +1 -0
- package/shared/dist/index.js +2 -0
- package/shared/dist/protocol.d.ts +614 -0
- package/shared/dist/protocol.js +30 -0
- package/client/build/_app/immutable/assets/0.Cj7UL9cq.css +0 -2
- package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
- package/client/build/_app/immutable/chunks/BEKHoMUP.js +0 -1
- package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
- package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
- package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
- package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
- package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
- package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
- package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
- package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
- package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { join, sep } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
2
5
|
import { resolveAllSlotPendingUi, resolveSlotPendingUi, replaySlotPendingUiRequests } from './session-manager.js';
|
|
3
6
|
import { getMergedPanelCards } from './panel-state.js';
|
|
4
7
|
import { createExtensionUIBridge } from './extension-ui-bridge.js';
|
|
5
8
|
import { findExternalPiProcesses, killExternalPiProcesses } from './takeover.js';
|
|
6
|
-
import { mapAgentMessages } from './message-mapper.js';
|
|
9
|
+
import { mapAgentMessages, extractMessageEntryIds, applyEntryIds } from './message-mapper.js';
|
|
10
|
+
import { getGitBranch } from './git-branch.js';
|
|
11
|
+
import { CallBindError } from './voice-orchestrator.js';
|
|
7
12
|
/** Parse data-URL encoded images into the shape the pi SDK expects. */
|
|
8
13
|
function parseDataUrlImages(images) {
|
|
9
14
|
if (!images || images.length === 0)
|
|
@@ -15,6 +20,66 @@ function parseDataUrlImages(images) {
|
|
|
15
20
|
return { type: 'image', data: match[2], mimeType: match[1] };
|
|
16
21
|
});
|
|
17
22
|
}
|
|
23
|
+
const TREE_PREVIEW_MAX_CHARS = 200;
|
|
24
|
+
function truncatePreview(value) {
|
|
25
|
+
if (value.length <= TREE_PREVIEW_MAX_CHARS)
|
|
26
|
+
return value;
|
|
27
|
+
return `${value.slice(0, TREE_PREVIEW_MAX_CHARS - 3)}...`;
|
|
28
|
+
}
|
|
29
|
+
function textFromEntryContent(content) {
|
|
30
|
+
if (typeof content === 'string')
|
|
31
|
+
return content;
|
|
32
|
+
if (!Array.isArray(content))
|
|
33
|
+
return '';
|
|
34
|
+
return content
|
|
35
|
+
.filter((block) => {
|
|
36
|
+
if (!block || typeof block !== 'object')
|
|
37
|
+
return false;
|
|
38
|
+
const candidate = block;
|
|
39
|
+
return candidate.type === 'text' && typeof candidate.text === 'string';
|
|
40
|
+
})
|
|
41
|
+
.map((block) => block.text)
|
|
42
|
+
.join('\n');
|
|
43
|
+
}
|
|
44
|
+
function previewForEntry(entry) {
|
|
45
|
+
if (entry.type === 'message') {
|
|
46
|
+
const messageContent = entry.message?.content;
|
|
47
|
+
const text = textFromEntryContent(messageContent);
|
|
48
|
+
return truncatePreview(text || entry.type);
|
|
49
|
+
}
|
|
50
|
+
if (entry.type === 'custom_message') {
|
|
51
|
+
const text = textFromEntryContent(entry.content);
|
|
52
|
+
return truncatePreview(text || entry.type);
|
|
53
|
+
}
|
|
54
|
+
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
|
|
55
|
+
const summary = typeof entry.summary === 'string' ? entry.summary : '';
|
|
56
|
+
return truncatePreview(summary || entry.type);
|
|
57
|
+
}
|
|
58
|
+
return entry.type;
|
|
59
|
+
}
|
|
60
|
+
/** Map pi SDK tree nodes to the wire transfer shape used by pimote clients. */
|
|
61
|
+
export function mapTreeNodes(nodes) {
|
|
62
|
+
return nodes.map((node) => {
|
|
63
|
+
const timestamp = typeof node.entry.timestamp === 'string' ? node.entry.timestamp : new Date(0).toISOString();
|
|
64
|
+
return {
|
|
65
|
+
id: node.entry.id,
|
|
66
|
+
type: node.entry.type,
|
|
67
|
+
role: node.entry.type === 'message'
|
|
68
|
+
? typeof node.entry.message.role === 'string'
|
|
69
|
+
? node.entry.message.role
|
|
70
|
+
: undefined
|
|
71
|
+
: 'role' in node.entry && typeof node.entry.role === 'string'
|
|
72
|
+
? node.entry.role
|
|
73
|
+
: undefined,
|
|
74
|
+
customType: 'customType' in node.entry && typeof node.entry.customType === 'string' ? node.entry.customType : undefined,
|
|
75
|
+
preview: previewForEntry(node.entry),
|
|
76
|
+
timestamp,
|
|
77
|
+
label: node.label,
|
|
78
|
+
labelTimestamp: node.labelTimestamp,
|
|
79
|
+
children: mapTreeNodes(node.children),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
18
83
|
/**
|
|
19
84
|
* Create command context actions for extension commands.
|
|
20
85
|
* Captures the ManagedSlot (stable lifetime), not a transient handler.
|
|
@@ -62,20 +127,6 @@ function createCommandContextActions(slot) {
|
|
|
62
127
|
reload: () => slot.session.reload(),
|
|
63
128
|
};
|
|
64
129
|
}
|
|
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
130
|
export class WsHandler {
|
|
80
131
|
sessionManager;
|
|
81
132
|
folderIndex;
|
|
@@ -83,16 +134,18 @@ export class WsHandler {
|
|
|
83
134
|
pushNotificationService;
|
|
84
135
|
sessionMetadataStore;
|
|
85
136
|
clientRegistry;
|
|
137
|
+
voiceOrchestrator;
|
|
86
138
|
subscribedSessions = new Set();
|
|
87
139
|
viewedSessionId = null;
|
|
88
140
|
clientId;
|
|
89
|
-
constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry) {
|
|
141
|
+
constructor(sessionManager, folderIndex, ws, pushNotificationService, sessionMetadataStore, clientId, clientRegistry, voiceOrchestrator) {
|
|
90
142
|
this.sessionManager = sessionManager;
|
|
91
143
|
this.folderIndex = folderIndex;
|
|
92
144
|
this.ws = ws;
|
|
93
145
|
this.pushNotificationService = pushNotificationService;
|
|
94
146
|
this.sessionMetadataStore = sessionMetadataStore;
|
|
95
147
|
this.clientRegistry = clientRegistry;
|
|
148
|
+
this.voiceOrchestrator = voiceOrchestrator;
|
|
96
149
|
this.clientId = clientId;
|
|
97
150
|
}
|
|
98
151
|
getViewedSessionId() {
|
|
@@ -131,7 +184,49 @@ export class WsHandler {
|
|
|
131
184
|
folder.activeStatus = null;
|
|
132
185
|
}
|
|
133
186
|
}
|
|
134
|
-
this.sendResponse(id, true, { folders });
|
|
187
|
+
this.sendResponse(id, true, { folders, roots: this.folderIndex.roots });
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'create_project': {
|
|
191
|
+
const name = command.name;
|
|
192
|
+
const root = command.root;
|
|
193
|
+
// Validate name: non-empty, no path separators, not . or ..
|
|
194
|
+
if (!name || name.includes('/') || name.includes(sep) || name === '.' || name === '..') {
|
|
195
|
+
this.sendResponse(id, false, undefined, 'Invalid project name');
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
// Validate root is one of the configured roots
|
|
199
|
+
if (!this.folderIndex.roots.includes(root)) {
|
|
200
|
+
this.sendResponse(id, false, undefined, 'Root is not a configured project root');
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
const folderPath = join(root, name);
|
|
204
|
+
// Check if directory already exists
|
|
205
|
+
try {
|
|
206
|
+
await stat(folderPath);
|
|
207
|
+
this.sendResponse(id, false, undefined, 'Directory already exists');
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const code = err instanceof Error ? err.code : undefined;
|
|
212
|
+
if (code !== 'ENOENT') {
|
|
213
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
214
|
+
this.sendResponse(id, false, undefined, `Cannot access directory: ${message}`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
// ENOENT — directory doesn't exist yet, proceed with creation
|
|
218
|
+
}
|
|
219
|
+
// Create directory and git init
|
|
220
|
+
try {
|
|
221
|
+
await mkdir(folderPath, { recursive: true });
|
|
222
|
+
await promisify(execFile)('git', ['init'], { cwd: folderPath });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
226
|
+
this.sendResponse(id, false, undefined, `Failed to create project: ${message}`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
this.sendResponse(id, true, { folderPath });
|
|
135
230
|
break;
|
|
136
231
|
}
|
|
137
232
|
case 'list_sessions': {
|
|
@@ -158,6 +253,7 @@ export class WsHandler {
|
|
|
158
253
|
archived: archivedLookup.get(s.path) === true,
|
|
159
254
|
isOwnedByMe: sl ? sl.connection?.connectedClientId === this.clientId : false,
|
|
160
255
|
liveStatus: sl ? sl.sessionState.status : null,
|
|
256
|
+
cwd: s.cwd !== command.folderPath ? s.cwd : undefined,
|
|
161
257
|
};
|
|
162
258
|
})
|
|
163
259
|
.filter((s) => command.includeArchived || !s.archived);
|
|
@@ -179,6 +275,7 @@ export class WsHandler {
|
|
|
179
275
|
archived: false,
|
|
180
276
|
isOwnedByMe: slot.connection?.connectedClientId === this.clientId,
|
|
181
277
|
liveStatus: slot.sessionState.status,
|
|
278
|
+
cwd: slot.folderPath !== command.folderPath ? slot.folderPath : undefined,
|
|
182
279
|
});
|
|
183
280
|
}
|
|
184
281
|
}
|
|
@@ -311,21 +408,23 @@ export class WsHandler {
|
|
|
311
408
|
break;
|
|
312
409
|
}
|
|
313
410
|
case 'archive_session': {
|
|
314
|
-
const
|
|
411
|
+
const archiveSessionIds = command.sessionIds;
|
|
315
412
|
const archiveFolderPath = command.folderPath;
|
|
316
|
-
if (!
|
|
317
|
-
this.sendResponse(id, false, undefined, '
|
|
413
|
+
if (!archiveSessionIds?.length || !archiveFolderPath) {
|
|
414
|
+
this.sendResponse(id, false, undefined, 'sessionIds and folderPath are required');
|
|
318
415
|
break;
|
|
319
416
|
}
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
417
|
+
let archivedCount = 0;
|
|
418
|
+
for (const archiveSessionId of archiveSessionIds) {
|
|
419
|
+
const archiveSlot = this.sessionManager.getSession(archiveSessionId);
|
|
420
|
+
const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
|
|
421
|
+
if (!archiveSessionPath)
|
|
422
|
+
continue;
|
|
423
|
+
await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
|
|
424
|
+
this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
|
|
425
|
+
archivedCount++;
|
|
325
426
|
}
|
|
326
|
-
|
|
327
|
-
this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
|
|
328
|
-
this.sendResponse(id, true, { archived: command.archived });
|
|
427
|
+
this.sendResponse(id, true, { archived: command.archived, count: archivedCount });
|
|
329
428
|
break;
|
|
330
429
|
}
|
|
331
430
|
case 'rename_session': {
|
|
@@ -384,6 +483,65 @@ export class WsHandler {
|
|
|
384
483
|
this.sendResponse(id, true, { sessionId: takeoverSessionId, killedProcesses: killedCount });
|
|
385
484
|
break;
|
|
386
485
|
}
|
|
486
|
+
// ---- Voice call control ----
|
|
487
|
+
case 'call_bind': {
|
|
488
|
+
if (!this.voiceOrchestrator) {
|
|
489
|
+
this.sendResponse(id, false, undefined, 'call_bind_failed_internal');
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
const slot = this.sessionManager.getSlot(command.sessionId);
|
|
493
|
+
if (!slot) {
|
|
494
|
+
this.sendResponse(id, false, undefined, 'call_bind_failed_session_not_found');
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
const connection = {
|
|
498
|
+
ws: this.ws,
|
|
499
|
+
connectedClientId: this.clientId,
|
|
500
|
+
onSessionReset: (s) => this.handleSessionReset(s),
|
|
501
|
+
};
|
|
502
|
+
try {
|
|
503
|
+
const data = await this.voiceOrchestrator.bindCall({
|
|
504
|
+
sessionId: command.sessionId,
|
|
505
|
+
clientConnection: connection,
|
|
506
|
+
force: command.force ?? false,
|
|
507
|
+
});
|
|
508
|
+
this.sendResponse(id, true, data);
|
|
509
|
+
this.sendEvent({ type: 'call_status', sessionId: command.sessionId, status: 'binding' });
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
if (err instanceof CallBindError) {
|
|
513
|
+
this.sendResponse(id, false, undefined, err.code);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
console.warn('[voice] call_bind failed', err);
|
|
517
|
+
this.sendResponse(id, false, undefined, 'call_bind_failed_internal');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'call_end': {
|
|
523
|
+
await this.voiceOrchestrator?.endCall({ sessionId: command.sessionId, reason: 'user_hangup' });
|
|
524
|
+
this.sendResponse(id, true);
|
|
525
|
+
this.sendEvent({ type: 'call_ended', sessionId: command.sessionId, reason: 'user_hangup' });
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
// ---- Client diagnostic logs (voice/call tracing) ----
|
|
529
|
+
case 'client_log': {
|
|
530
|
+
// Forward to the server's logger so client-side traces interleave
|
|
531
|
+
// with the server-side voice extension logs in the same journal.
|
|
532
|
+
const clientWall = new Date(command.clientTimestampMs).toISOString();
|
|
533
|
+
const serverWall = new Date().toISOString();
|
|
534
|
+
const driftMs = Date.now() - command.clientTimestampMs;
|
|
535
|
+
const line = `[voice_trace][client/${command.tag}] ${command.message} ${JSON.stringify({ clientWall, serverWall, driftMs, ...(command.data ?? {}) })}`;
|
|
536
|
+
if (command.level === 'error')
|
|
537
|
+
console.error(line);
|
|
538
|
+
else if (command.level === 'warn')
|
|
539
|
+
console.warn(line);
|
|
540
|
+
else
|
|
541
|
+
console.log(line);
|
|
542
|
+
this.sendResponse(id, true);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
387
545
|
// ---- Extension UI ----
|
|
388
546
|
case 'extension_ui_response': {
|
|
389
547
|
const uiSlot = command.sessionId ? this.sessionManager.getSession(command.sessionId) : undefined;
|
|
@@ -495,7 +653,10 @@ export class WsHandler {
|
|
|
495
653
|
case 'get_commands':
|
|
496
654
|
case 'complete_args':
|
|
497
655
|
case 'set_session_name':
|
|
498
|
-
case 'dequeue_steering':
|
|
656
|
+
case 'dequeue_steering':
|
|
657
|
+
case 'fork':
|
|
658
|
+
case 'navigate_tree':
|
|
659
|
+
case 'set_tree_label': {
|
|
499
660
|
await this.handleSessionCommand(command, id);
|
|
500
661
|
break;
|
|
501
662
|
}
|
|
@@ -538,6 +699,12 @@ export class WsHandler {
|
|
|
538
699
|
this.sendResponse(id, true);
|
|
539
700
|
break;
|
|
540
701
|
}
|
|
702
|
+
if (trimmed === '/tree') {
|
|
703
|
+
const tree = mapTreeNodes(session.sessionManager.getTree());
|
|
704
|
+
const currentLeafId = session.sessionManager.getLeafId();
|
|
705
|
+
this.sendResponse(id, true, { tree, currentLeafId });
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
541
708
|
session.prompt(command.message, { images: parseDataUrlImages(command.images) }).catch((err) => {
|
|
542
709
|
console.error(`[WsHandler] prompt error:`, err);
|
|
543
710
|
});
|
|
@@ -566,7 +733,10 @@ export class WsHandler {
|
|
|
566
733
|
case 'abort': {
|
|
567
734
|
// Resolve pending UI responses first so stuck dialogs unblock
|
|
568
735
|
resolveAllSlotPendingUi(slot);
|
|
569
|
-
await session.abort();
|
|
736
|
+
const abortResult = await Promise.race([session.abort().then(() => 'ok'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000))]);
|
|
737
|
+
if (abortResult === 'timeout') {
|
|
738
|
+
console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
|
|
739
|
+
}
|
|
570
740
|
this.sendResponse(id, true);
|
|
571
741
|
break;
|
|
572
742
|
}
|
|
@@ -630,6 +800,7 @@ export class WsHandler {
|
|
|
630
800
|
const state = {
|
|
631
801
|
model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
|
|
632
802
|
thinkingLevel: session.thinkingLevel,
|
|
803
|
+
availableThinkingLevels: session.getAvailableThinkingLevels(),
|
|
633
804
|
isStreaming: session.isStreaming,
|
|
634
805
|
isCompacting: session.isCompacting,
|
|
635
806
|
sessionFile: session.sessionFile,
|
|
@@ -643,6 +814,8 @@ export class WsHandler {
|
|
|
643
814
|
}
|
|
644
815
|
case 'get_messages': {
|
|
645
816
|
const messages = mapAgentMessages(session.messages);
|
|
817
|
+
const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
|
|
818
|
+
applyEntryIds(messages, entryIds);
|
|
646
819
|
this.sendResponse(id, true, { messages });
|
|
647
820
|
break;
|
|
648
821
|
}
|
|
@@ -696,7 +869,7 @@ export class WsHandler {
|
|
|
696
869
|
});
|
|
697
870
|
}
|
|
698
871
|
// Pimote built-in commands
|
|
699
|
-
commands.push({ name: 'new', description: 'Start a new session', hasArgCompletions: false }, { name: 'reload', description: 'Reload extensions and skills', hasArgCompletions: false });
|
|
872
|
+
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 });
|
|
700
873
|
this.sendResponse(id, true, { commands });
|
|
701
874
|
break;
|
|
702
875
|
}
|
|
@@ -720,16 +893,91 @@ export class WsHandler {
|
|
|
720
893
|
this.sendResponse(id, true);
|
|
721
894
|
break;
|
|
722
895
|
}
|
|
896
|
+
case 'fork': {
|
|
897
|
+
if (!command.entryId) {
|
|
898
|
+
this.sendResponse(id, false, undefined, 'entryId is required');
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
const forkResult = await slot.runtime.fork(command.entryId);
|
|
902
|
+
if (!forkResult.cancelled) {
|
|
903
|
+
await this.handleSessionReset(slot);
|
|
904
|
+
}
|
|
905
|
+
const forkData = { cancelled: forkResult.cancelled };
|
|
906
|
+
if (forkResult.selectedText !== undefined) {
|
|
907
|
+
forkData.selectedText = forkResult.selectedText;
|
|
908
|
+
}
|
|
909
|
+
this.sendResponse(id, true, forkData);
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
case 'navigate_tree': {
|
|
913
|
+
if (slot.sessionState.treeNavigationInProgress) {
|
|
914
|
+
this.sendResponse(id, false, undefined, 'Tree navigation already in progress');
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
const options = {};
|
|
918
|
+
if (command.summarize !== undefined)
|
|
919
|
+
options.summarize = command.summarize;
|
|
920
|
+
if (command.customInstructions !== undefined)
|
|
921
|
+
options.customInstructions = command.customInstructions;
|
|
922
|
+
if (command.replaceInstructions !== undefined)
|
|
923
|
+
options.replaceInstructions = command.replaceInstructions;
|
|
924
|
+
if (command.label !== undefined)
|
|
925
|
+
options.label = command.label;
|
|
926
|
+
slot.sessionState.treeNavigationInProgress = true;
|
|
927
|
+
this.emitBufferedSessionEvent(slot, sessionId, {
|
|
928
|
+
type: 'tree_navigation_start',
|
|
929
|
+
targetId: command.targetId,
|
|
930
|
+
summarizing: !!command.summarize,
|
|
931
|
+
});
|
|
932
|
+
let result;
|
|
933
|
+
try {
|
|
934
|
+
result = (await session.navigateTree(command.targetId, options));
|
|
935
|
+
}
|
|
936
|
+
finally {
|
|
937
|
+
slot.sessionState.treeNavigationInProgress = false;
|
|
938
|
+
this.emitBufferedSessionEvent(slot, sessionId, {
|
|
939
|
+
type: 'tree_navigation_end',
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
if (!result.cancelled) {
|
|
943
|
+
await this.handleSessionReset(slot);
|
|
944
|
+
}
|
|
945
|
+
const data = { cancelled: result.cancelled };
|
|
946
|
+
if (result.editorText !== undefined) {
|
|
947
|
+
data.editorText = result.editorText;
|
|
948
|
+
}
|
|
949
|
+
this.sendResponse(id, true, data);
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
case 'set_tree_label': {
|
|
953
|
+
const normalizedLabel = command.label === '' ? undefined : command.label;
|
|
954
|
+
session.sessionManager.appendLabelChange(command.entryId, normalizedLabel);
|
|
955
|
+
this.sendResponse(id, true, { success: true });
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
723
958
|
}
|
|
724
959
|
}
|
|
725
960
|
/** Notify the old owner that they've been displaced from a session.
|
|
726
|
-
* No-op if the session is unowned or owned by this client.
|
|
961
|
+
* No-op if the session is unowned or owned by this client.
|
|
962
|
+
*
|
|
963
|
+
* Voice-call tear-down on displacement lives in `sendDisplacedEvent` (the
|
|
964
|
+
* old-owner-side site that also emits `call_ended { displaced }`), so this
|
|
965
|
+
* method does not call `voiceOrchestrator.endCall` itself — see review
|
|
966
|
+
* finding 4.
|
|
967
|
+
*/
|
|
727
968
|
displaceOwner(sessionId, slot) {
|
|
728
969
|
if (slot.connection?.connectedClientId && slot.connection.connectedClientId !== this.clientId) {
|
|
729
970
|
const oldHandler = this.clientRegistry.get(slot.connection.connectedClientId);
|
|
730
971
|
if (oldHandler) {
|
|
731
972
|
oldHandler.sendDisplacedEvent(sessionId);
|
|
732
973
|
}
|
|
974
|
+
else if (this.voiceOrchestrator?.isCallActive(sessionId)) {
|
|
975
|
+
// Stale owner id with no live handler — clean up orchestrator state
|
|
976
|
+
// so the new owner doesn't inherit a phantom active call.
|
|
977
|
+
this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' }).catch((err) => {
|
|
978
|
+
console.warn('[voice] endCall on displace (stale handler) failed', err);
|
|
979
|
+
});
|
|
980
|
+
}
|
|
733
981
|
}
|
|
734
982
|
}
|
|
735
983
|
/** Bind a slot to this client — sets ownership, WebSocket routing,
|
|
@@ -747,7 +995,9 @@ export class WsHandler {
|
|
|
747
995
|
// ManagedSlot — on reconnect we skip rebinding, but on session reset
|
|
748
996
|
// we must rebind so the bridge points at the new session state.
|
|
749
997
|
if (!slot.sessionState.extensionsBound) {
|
|
750
|
-
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService
|
|
998
|
+
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
|
|
999
|
+
isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(sessionId) ?? false,
|
|
1000
|
+
});
|
|
751
1001
|
const commandContextActions = createCommandContextActions(slot);
|
|
752
1002
|
await slot.session.bindExtensions({ uiContext, commandContextActions });
|
|
753
1003
|
slot.sessionState.extensionsBound = true;
|
|
@@ -766,9 +1016,12 @@ export class WsHandler {
|
|
|
766
1016
|
return;
|
|
767
1017
|
}
|
|
768
1018
|
// Session ID changed — rebuild session state in-place on the same slot.
|
|
769
|
-
|
|
1019
|
+
// rebuildSessionState refreshes slot.folderPath from the new session's header cwd,
|
|
1020
|
+
// so capture folderPath AFTER the rebuild to pick up the new value (fork-from can
|
|
1021
|
+
// change cwd, e.g. the worktree extension).
|
|
770
1022
|
// Rebuild session state (tears down old, creates new from runtime.session)
|
|
771
1023
|
this.sessionManager.rebuildSessionState(slot);
|
|
1024
|
+
const folderPath = slot.folderPath;
|
|
772
1025
|
// Re-key the session map
|
|
773
1026
|
this.sessionManager.reKeySession(slot, oldSessionId, newSessionId);
|
|
774
1027
|
// Update handler bookkeeping
|
|
@@ -778,7 +1031,9 @@ export class WsHandler {
|
|
|
778
1031
|
this.viewedSessionId = newSessionId;
|
|
779
1032
|
}
|
|
780
1033
|
// Rebind extension UI bridge (new session state for dialog routing)
|
|
781
|
-
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService
|
|
1034
|
+
const uiContext = createExtensionUIBridge(slot, this.pushNotificationService, {
|
|
1035
|
+
isVoiceModeActive: () => this.voiceOrchestrator?.isCallActive(newSessionId) ?? false,
|
|
1036
|
+
});
|
|
782
1037
|
const commandContextActions = createCommandContextActions(slot);
|
|
783
1038
|
await slot.session.bindExtensions({ uiContext, commandContextActions });
|
|
784
1039
|
slot.sessionState.extensionsBound = true;
|
|
@@ -891,6 +1146,27 @@ export class WsHandler {
|
|
|
891
1146
|
sessionId,
|
|
892
1147
|
reason: 'displaced',
|
|
893
1148
|
});
|
|
1149
|
+
// If the old owner had an active voice call on this session, tear down
|
|
1150
|
+
// orchestrator bookkeeping and surface `call_ended { reason: 'displaced' }`
|
|
1151
|
+
// so their VoiceCallStore tears down alongside the session_closed.
|
|
1152
|
+
if (this.voiceOrchestrator?.isCallActive(sessionId)) {
|
|
1153
|
+
void this.voiceOrchestrator.endCall({ sessionId, reason: 'displaced' });
|
|
1154
|
+
this.sendEvent({
|
|
1155
|
+
type: 'call_ended',
|
|
1156
|
+
sessionId,
|
|
1157
|
+
reason: 'displaced',
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/** Broadcast a `call_ended` to this client (used by the session manager's
|
|
1162
|
+
* before-close hook so the orchestrator bookkeeping owner learns that a
|
|
1163
|
+
* server-initiated teardown happened). */
|
|
1164
|
+
sendCallEndedEvent(sessionId, reason) {
|
|
1165
|
+
this.sendEvent({
|
|
1166
|
+
type: 'call_ended',
|
|
1167
|
+
sessionId,
|
|
1168
|
+
reason,
|
|
1169
|
+
});
|
|
894
1170
|
}
|
|
895
1171
|
/** Send a session_closed event with reason 'killed' to this client's WebSocket.
|
|
896
1172
|
* Also removes the session from this handler's subscribedSessions so that
|
|
@@ -935,6 +1211,30 @@ export class WsHandler {
|
|
|
935
1211
|
console.error('[WsHandler] Failed to send event:', err);
|
|
936
1212
|
}
|
|
937
1213
|
}
|
|
1214
|
+
emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
|
|
1215
|
+
let forwarded = false;
|
|
1216
|
+
slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
|
|
1217
|
+
// Augment agent_end with message entry IDs so the client can enable
|
|
1218
|
+
// fork targets on messages that arrived via streaming (without IDs).
|
|
1219
|
+
if (event.type === 'agent_end') {
|
|
1220
|
+
const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
|
|
1221
|
+
event.messageEntryIds = entryIds;
|
|
1222
|
+
}
|
|
1223
|
+
forwarded = true;
|
|
1224
|
+
this.sendEvent(event);
|
|
1225
|
+
}, () => slot.session.messages[slot.session.messages.length - 1]);
|
|
1226
|
+
// Test doubles for EventBuffer may no-op on onEvent().
|
|
1227
|
+
// Fallback keeps lifecycle visibility in tests while real runtime uses buffered forwarding above.
|
|
1228
|
+
if (!forwarded) {
|
|
1229
|
+
const cursorBase = slot.sessionState.eventBuffer.currentCursor;
|
|
1230
|
+
const cursor = typeof cursorBase === 'number' ? cursorBase + 1 : 1;
|
|
1231
|
+
this.sendEvent({
|
|
1232
|
+
...sdkEvent,
|
|
1233
|
+
sessionId,
|
|
1234
|
+
cursor,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
938
1238
|
/** Send a full_resync event to the client for the given managed session.
|
|
939
1239
|
* Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
|
|
940
1240
|
sendFullResyncForSession(pimoteSessionId, slot) {
|
|
@@ -943,6 +1243,7 @@ export class WsHandler {
|
|
|
943
1243
|
const state = {
|
|
944
1244
|
model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
|
|
945
1245
|
thinkingLevel: session.thinkingLevel,
|
|
1246
|
+
availableThinkingLevels: session.getAvailableThinkingLevels(),
|
|
946
1247
|
isStreaming: session.isStreaming,
|
|
947
1248
|
isCompacting: session.isCompacting,
|
|
948
1249
|
sessionFile: session.sessionFile,
|
|
@@ -952,6 +1253,8 @@ export class WsHandler {
|
|
|
952
1253
|
messageCount: session.messages.length,
|
|
953
1254
|
};
|
|
954
1255
|
const messages = mapAgentMessages(session.messages);
|
|
1256
|
+
const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
|
|
1257
|
+
applyEntryIds(messages, entryIds);
|
|
955
1258
|
const fullResyncEvent = {
|
|
956
1259
|
type: 'full_resync',
|
|
957
1260
|
sessionId: pimoteSessionId,
|
|
@@ -1007,6 +1310,7 @@ export class WsHandler {
|
|
|
1007
1310
|
sessionName,
|
|
1008
1311
|
firstMessage,
|
|
1009
1312
|
messageCount,
|
|
1313
|
+
gitBranch: slot ? getGitBranch(slot.folderPath) : null,
|
|
1010
1314
|
};
|
|
1011
1315
|
for (const [, handler] of clientRegistry) {
|
|
1012
1316
|
handler.sendToClient(event);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './protocol.js';
|