@pimote/pimote 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +3 -1
  2. package/client/build/_app/immutable/assets/0.DBrr7n4n.css +2 -0
  3. package/client/build/_app/immutable/assets/2.DE6k3bQj.css +1 -0
  4. package/client/build/_app/immutable/chunks/5vSSf6qG.js +5 -0
  5. package/client/build/_app/immutable/chunks/{BTSGQ0LP.js → B8lQCytv.js} +1 -1
  6. package/client/build/_app/immutable/chunks/{BEKHoMUP.js → CT6ckxpD.js} +1 -1
  7. package/client/build/_app/immutable/chunks/DlJOVoUQ.js +1 -0
  8. package/client/build/_app/immutable/chunks/YxmLwfhj.js +1 -0
  9. package/client/build/_app/immutable/chunks/{L5t1qIFa.js → uZO1iyJZ.js} +2 -2
  10. package/client/build/_app/immutable/chunks/yWVx3W2o.js +1 -0
  11. package/client/build/_app/immutable/entry/app.CNzpBgAg.js +2 -0
  12. package/client/build/_app/immutable/entry/start.DYkTAHh1.js +1 -0
  13. package/client/build/_app/immutable/nodes/0.DNlQhEb_.js +10 -0
  14. package/client/build/_app/immutable/nodes/1.B8zmHMre.js +1 -0
  15. package/client/build/_app/immutable/nodes/2.W9yV4-x2.js +54 -0
  16. package/client/build/_app/version.json +1 -1
  17. package/client/build/index.html +8 -8
  18. package/package.json +3 -3
  19. package/patches/{@mariozechner+pi-coding-agent+0.65.0.patch → @mariozechner+pi-coding-agent+0.67.6.patch} +4 -4
  20. package/server/dist/folder-index.js +8 -4
  21. package/server/dist/git-branch.js +32 -0
  22. package/server/dist/message-mapper.js +65 -2
  23. package/server/dist/server.js +3 -0
  24. package/server/dist/session-manager.js +35 -4
  25. package/server/dist/ws-handler.js +232 -31
  26. package/client/build/_app/immutable/assets/0.Cj7UL9cq.css +0 -2
  27. package/client/build/_app/immutable/assets/2.CIRqqeIr.css +0 -1
  28. package/client/build/_app/immutable/chunks/CfQ6Egqh.js +0 -1
  29. package/client/build/_app/immutable/chunks/DQ-KfPq0.js +0 -1
  30. package/client/build/_app/immutable/chunks/DfA0ecbz.js +0 -1
  31. package/client/build/_app/immutable/chunks/Dnh9Emns.js +0 -5
  32. package/client/build/_app/immutable/entry/app.j0V4R67V.js +0 -2
  33. package/client/build/_app/immutable/entry/start.wkfo4Ebw.js +0 -1
  34. package/client/build/_app/immutable/nodes/0.CUipL_P7.js +0 -5
  35. package/client/build/_app/immutable/nodes/1.ex7ejMby.js +0 -1
  36. package/client/build/_app/immutable/nodes/2.165oQG9Z.js +0 -49
@@ -1,6 +1,7 @@
1
1
  import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, createEventBus, AuthStorage, ModelRegistry, getAgentDir, SessionManager as PiSessionManager, } from '@mariozechner/pi-coding-agent';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { applyPanelMessage, getMergedPanelCards } from './panel-state.js';
4
+ import { getGitBranch } from './git-branch.js';
4
5
  // ---- Slot-based helpers (operate on ManagedSlot) ----
5
6
  /** Send an event to the client connected to this slot. No-op if disconnected. */
6
7
  export function sendSlotEvent(slot, event) {
@@ -59,6 +60,7 @@ function createSessionState(session, eventBus, config, callbacks, slotRef, folde
59
60
  panelState: new Map(),
60
61
  panelListenerUnsubs: [],
61
62
  panelThrottleTimer: null,
63
+ treeNavigationInProgress: false,
62
64
  };
63
65
  // Subscribe to session events
64
66
  const unsubscribe = session.subscribe((event) => {
@@ -125,8 +127,11 @@ export class PimoteSessionManager {
125
127
  modelRegistry;
126
128
  sessions = new Map();
127
129
  idleCheckHandle = null;
130
+ gitBranchCheckHandle = null;
131
+ lastKnownGitBranchBySession = new Map();
128
132
  onStatusChange;
129
133
  onSessionClosed;
134
+ onGitBranchChange;
130
135
  constructor(config, pushNotificationService) {
131
136
  this.config = config;
132
137
  this.pushNotificationService = pushNotificationService;
@@ -137,6 +142,8 @@ export class PimoteSessionManager {
137
142
  const eventBusRef = { current: null };
138
143
  const sharedAuthStorage = this.authStorage;
139
144
  const sharedModelRegistry = this.modelRegistry;
145
+ const sessionManager = sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath);
146
+ const effectiveFolderPath = sessionFilePath ? sessionManager.getCwd() : folderPath;
140
147
  const factory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
141
148
  const eventBus = createEventBus();
142
149
  eventBusRef.current = eventBus;
@@ -154,9 +161,9 @@ export class PimoteSessionManager {
154
161
  };
155
162
  };
156
163
  const runtime = await createAgentSessionRuntime(factory, {
157
- cwd: folderPath,
164
+ cwd: effectiveFolderPath,
158
165
  agentDir: getAgentDir(),
159
- sessionManager: sessionFilePath ? PiSessionManager.open(sessionFilePath) : PiSessionManager.create(folderPath),
166
+ sessionManager,
160
167
  });
161
168
  const session = runtime.session;
162
169
  const sessionId = session.sessionId;
@@ -186,10 +193,10 @@ export class PimoteSessionManager {
186
193
  onStatusChange: (sid, fp) => this.onStatusChange?.(sid, fp),
187
194
  onAgentEnd: (sid, s) => this.handleAgentEnd(sid, s),
188
195
  sendEvent: (e) => sendSlotEvent(slot, e),
189
- }, slotRef, folderPath);
196
+ }, slotRef, effectiveFolderPath);
190
197
  const slot = {
191
198
  runtime,
192
- folderPath,
199
+ folderPath: effectiveFolderPath,
193
200
  eventBusRef,
194
201
  connection: null,
195
202
  sessionState,
@@ -199,6 +206,7 @@ export class PimoteSessionManager {
199
206
  };
200
207
  slotRef.slot = slot;
201
208
  this.sessions.set(sessionId, slot);
209
+ this.lastKnownGitBranchBySession.set(sessionId, getGitBranch(effectiveFolderPath));
202
210
  return sessionId;
203
211
  }
204
212
  handleAgentEnd(sessionId, slot) {
@@ -261,12 +269,16 @@ export class PimoteSessionManager {
261
269
  const folderPath = slot.folderPath;
262
270
  await slot.runtime.dispose();
263
271
  this.sessions.delete(sessionId);
272
+ this.lastKnownGitBranchBySession.delete(sessionId);
264
273
  this.onSessionClosed?.(sessionId, folderPath);
265
274
  }
266
275
  /** Re-key a slot in the session map after session replacement. */
267
276
  reKeySession(slot, oldId, newId) {
268
277
  this.sessions.delete(oldId);
269
278
  this.sessions.set(newId, slot);
279
+ const lastKnown = this.lastKnownGitBranchBySession.get(oldId) ?? null;
280
+ this.lastKnownGitBranchBySession.delete(oldId);
281
+ this.lastKnownGitBranchBySession.set(newId, lastKnown);
270
282
  }
271
283
  /** Rebuild a slot's SessionState after session replacement.
272
284
  * Tears down the old state and creates a new one from the current runtime.session. */
@@ -289,6 +301,9 @@ export class PimoteSessionManager {
289
301
  this.stopIdleCheck();
290
302
  this.idleCheckHandle = setInterval(() => {
291
303
  for (const [sessionId, slot] of this.sessions) {
304
+ if (slot.sessionState.treeNavigationInProgress) {
305
+ continue;
306
+ }
292
307
  const clientId = slot.connection?.connectedClientId ?? null;
293
308
  const hasConnectedClient = clientId !== null && (isClientConnected?.(clientId) ?? false);
294
309
  if (!hasConnectedClient && Date.now() - slot.sessionState.lastActivity > idleTimeout) {
@@ -298,12 +313,28 @@ export class PimoteSessionManager {
298
313
  }
299
314
  }
300
315
  }, 60_000);
316
+ this.gitBranchCheckHandle = setInterval(() => {
317
+ for (const [sessionId, slot] of this.sessions) {
318
+ if (!slot.connection?.connectedClientId)
319
+ continue;
320
+ const next = getGitBranch(slot.folderPath);
321
+ const prev = this.lastKnownGitBranchBySession.get(sessionId) ?? null;
322
+ if (next !== prev) {
323
+ this.lastKnownGitBranchBySession.set(sessionId, next);
324
+ this.onGitBranchChange?.(sessionId, slot.folderPath);
325
+ }
326
+ }
327
+ }, 3000);
301
328
  }
302
329
  stopIdleCheck() {
303
330
  if (this.idleCheckHandle !== null) {
304
331
  clearInterval(this.idleCheckHandle);
305
332
  this.idleCheckHandle = null;
306
333
  }
334
+ if (this.gitBranchCheckHandle !== null) {
335
+ clearInterval(this.gitBranchCheckHandle);
336
+ this.gitBranchCheckHandle = null;
337
+ }
307
338
  }
308
339
  async dispose() {
309
340
  this.stopIdleCheck();
@@ -1,9 +1,13 @@
1
- import { execFileSync } from 'node:child_process';
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';
7
11
  /** Parse data-URL encoded images into the shape the pi SDK expects. */
8
12
  function parseDataUrlImages(images) {
9
13
  if (!images || images.length === 0)
@@ -15,6 +19,66 @@ function parseDataUrlImages(images) {
15
19
  return { type: 'image', data: match[2], mimeType: match[1] };
16
20
  });
17
21
  }
22
+ const TREE_PREVIEW_MAX_CHARS = 200;
23
+ function truncatePreview(value) {
24
+ if (value.length <= TREE_PREVIEW_MAX_CHARS)
25
+ return value;
26
+ return `${value.slice(0, TREE_PREVIEW_MAX_CHARS - 3)}...`;
27
+ }
28
+ function textFromEntryContent(content) {
29
+ if (typeof content === 'string')
30
+ return content;
31
+ if (!Array.isArray(content))
32
+ return '';
33
+ return content
34
+ .filter((block) => {
35
+ if (!block || typeof block !== 'object')
36
+ return false;
37
+ const candidate = block;
38
+ return candidate.type === 'text' && typeof candidate.text === 'string';
39
+ })
40
+ .map((block) => block.text)
41
+ .join('\n');
42
+ }
43
+ function previewForEntry(entry) {
44
+ if (entry.type === 'message') {
45
+ const messageContent = entry.message?.content;
46
+ const text = textFromEntryContent(messageContent);
47
+ return truncatePreview(text || entry.type);
48
+ }
49
+ if (entry.type === 'custom_message') {
50
+ const text = textFromEntryContent(entry.content);
51
+ return truncatePreview(text || entry.type);
52
+ }
53
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') {
54
+ const summary = typeof entry.summary === 'string' ? entry.summary : '';
55
+ return truncatePreview(summary || entry.type);
56
+ }
57
+ return entry.type;
58
+ }
59
+ /** Map pi SDK tree nodes to the wire transfer shape used by pimote clients. */
60
+ export function mapTreeNodes(nodes) {
61
+ return nodes.map((node) => {
62
+ const timestamp = typeof node.entry.timestamp === 'string' ? node.entry.timestamp : new Date(0).toISOString();
63
+ return {
64
+ id: node.entry.id,
65
+ type: node.entry.type,
66
+ role: node.entry.type === 'message'
67
+ ? typeof node.entry.message.role === 'string'
68
+ ? node.entry.message.role
69
+ : undefined
70
+ : 'role' in node.entry && typeof node.entry.role === 'string'
71
+ ? node.entry.role
72
+ : undefined,
73
+ customType: 'customType' in node.entry && typeof node.entry.customType === 'string' ? node.entry.customType : undefined,
74
+ preview: previewForEntry(node.entry),
75
+ timestamp,
76
+ label: node.label,
77
+ labelTimestamp: node.labelTimestamp,
78
+ children: mapTreeNodes(node.children),
79
+ };
80
+ });
81
+ }
18
82
  /**
19
83
  * Create command context actions for extension commands.
20
84
  * Captures the ManagedSlot (stable lifetime), not a transient handler.
@@ -62,20 +126,6 @@ function createCommandContextActions(slot) {
62
126
  reload: () => slot.session.reload(),
63
127
  };
64
128
  }
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
129
  export class WsHandler {
80
130
  sessionManager;
81
131
  folderIndex;
@@ -131,7 +181,49 @@ export class WsHandler {
131
181
  folder.activeStatus = null;
132
182
  }
133
183
  }
134
- this.sendResponse(id, true, { folders });
184
+ this.sendResponse(id, true, { folders, roots: this.folderIndex.roots });
185
+ break;
186
+ }
187
+ case 'create_project': {
188
+ const name = command.name;
189
+ const root = command.root;
190
+ // Validate name: non-empty, no path separators, not . or ..
191
+ if (!name || name.includes('/') || name.includes(sep) || name === '.' || name === '..') {
192
+ this.sendResponse(id, false, undefined, 'Invalid project name');
193
+ break;
194
+ }
195
+ // Validate root is one of the configured roots
196
+ if (!this.folderIndex.roots.includes(root)) {
197
+ this.sendResponse(id, false, undefined, 'Root is not a configured project root');
198
+ break;
199
+ }
200
+ const folderPath = join(root, name);
201
+ // Check if directory already exists
202
+ try {
203
+ await stat(folderPath);
204
+ this.sendResponse(id, false, undefined, 'Directory already exists');
205
+ break;
206
+ }
207
+ catch (err) {
208
+ const code = err instanceof Error ? err.code : undefined;
209
+ if (code !== 'ENOENT') {
210
+ const message = err instanceof Error ? err.message : String(err);
211
+ this.sendResponse(id, false, undefined, `Cannot access directory: ${message}`);
212
+ break;
213
+ }
214
+ // ENOENT — directory doesn't exist yet, proceed with creation
215
+ }
216
+ // Create directory and git init
217
+ try {
218
+ await mkdir(folderPath, { recursive: true });
219
+ await promisify(execFile)('git', ['init'], { cwd: folderPath });
220
+ }
221
+ catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ this.sendResponse(id, false, undefined, `Failed to create project: ${message}`);
224
+ break;
225
+ }
226
+ this.sendResponse(id, true, { folderPath });
135
227
  break;
136
228
  }
137
229
  case 'list_sessions': {
@@ -158,6 +250,7 @@ export class WsHandler {
158
250
  archived: archivedLookup.get(s.path) === true,
159
251
  isOwnedByMe: sl ? sl.connection?.connectedClientId === this.clientId : false,
160
252
  liveStatus: sl ? sl.sessionState.status : null,
253
+ cwd: s.cwd !== command.folderPath ? s.cwd : undefined,
161
254
  };
162
255
  })
163
256
  .filter((s) => command.includeArchived || !s.archived);
@@ -179,6 +272,7 @@ export class WsHandler {
179
272
  archived: false,
180
273
  isOwnedByMe: slot.connection?.connectedClientId === this.clientId,
181
274
  liveStatus: slot.sessionState.status,
275
+ cwd: slot.folderPath !== command.folderPath ? slot.folderPath : undefined,
182
276
  });
183
277
  }
184
278
  }
@@ -311,21 +405,23 @@ export class WsHandler {
311
405
  break;
312
406
  }
313
407
  case 'archive_session': {
314
- const archiveSessionId = command.sessionId;
408
+ const archiveSessionIds = command.sessionIds;
315
409
  const archiveFolderPath = command.folderPath;
316
- if (!archiveSessionId || !archiveFolderPath) {
317
- this.sendResponse(id, false, undefined, 'sessionId and folderPath are required');
410
+ if (!archiveSessionIds?.length || !archiveFolderPath) {
411
+ this.sendResponse(id, false, undefined, 'sessionIds and folderPath are required');
318
412
  break;
319
413
  }
320
- const archiveSlot = this.sessionManager.getSession(archiveSessionId);
321
- const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
322
- if (!archiveSessionPath) {
323
- this.sendResponse(id, false, undefined, `Session not found: ${archiveSessionId}`);
324
- break;
414
+ let archivedCount = 0;
415
+ for (const archiveSessionId of archiveSessionIds) {
416
+ const archiveSlot = this.sessionManager.getSession(archiveSessionId);
417
+ const archiveSessionPath = archiveSlot?.session.sessionFile ?? (await this.folderIndex.resolveSessionPath(archiveFolderPath, archiveSessionId));
418
+ if (!archiveSessionPath)
419
+ continue;
420
+ await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
421
+ this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
422
+ archivedCount++;
325
423
  }
326
- await this.sessionMetadataStore.setArchived(archiveSessionPath, command.archived);
327
- this.broadcastSessionArchived(archiveSessionId, archiveFolderPath, command.archived);
328
- this.sendResponse(id, true, { archived: command.archived });
424
+ this.sendResponse(id, true, { archived: command.archived, count: archivedCount });
329
425
  break;
330
426
  }
331
427
  case 'rename_session': {
@@ -495,7 +591,10 @@ export class WsHandler {
495
591
  case 'get_commands':
496
592
  case 'complete_args':
497
593
  case 'set_session_name':
498
- case 'dequeue_steering': {
594
+ case 'dequeue_steering':
595
+ case 'fork':
596
+ case 'navigate_tree':
597
+ case 'set_tree_label': {
499
598
  await this.handleSessionCommand(command, id);
500
599
  break;
501
600
  }
@@ -538,6 +637,12 @@ export class WsHandler {
538
637
  this.sendResponse(id, true);
539
638
  break;
540
639
  }
640
+ if (trimmed === '/tree') {
641
+ const tree = mapTreeNodes(session.sessionManager.getTree());
642
+ const currentLeafId = session.sessionManager.getLeafId();
643
+ this.sendResponse(id, true, { tree, currentLeafId });
644
+ break;
645
+ }
541
646
  session.prompt(command.message, { images: parseDataUrlImages(command.images) }).catch((err) => {
542
647
  console.error(`[WsHandler] prompt error:`, err);
543
648
  });
@@ -566,7 +671,10 @@ export class WsHandler {
566
671
  case 'abort': {
567
672
  // Resolve pending UI responses first so stuck dialogs unblock
568
673
  resolveAllSlotPendingUi(slot);
569
- await session.abort();
674
+ const abortResult = await Promise.race([session.abort().then(() => 'ok'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 30_000))]);
675
+ if (abortResult === 'timeout') {
676
+ console.error(`[WsHandler] session.abort() did not resolve within 30s (sessionId=${sessionId})`);
677
+ }
570
678
  this.sendResponse(id, true);
571
679
  break;
572
680
  }
@@ -630,6 +738,7 @@ export class WsHandler {
630
738
  const state = {
631
739
  model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
632
740
  thinkingLevel: session.thinkingLevel,
741
+ availableThinkingLevels: session.getAvailableThinkingLevels(),
633
742
  isStreaming: session.isStreaming,
634
743
  isCompacting: session.isCompacting,
635
744
  sessionFile: session.sessionFile,
@@ -643,6 +752,8 @@ export class WsHandler {
643
752
  }
644
753
  case 'get_messages': {
645
754
  const messages = mapAgentMessages(session.messages);
755
+ const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
756
+ applyEntryIds(messages, entryIds);
646
757
  this.sendResponse(id, true, { messages });
647
758
  break;
648
759
  }
@@ -696,7 +807,7 @@ export class WsHandler {
696
807
  });
697
808
  }
698
809
  // 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 });
810
+ 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
811
  this.sendResponse(id, true, { commands });
701
812
  break;
702
813
  }
@@ -720,6 +831,68 @@ export class WsHandler {
720
831
  this.sendResponse(id, true);
721
832
  break;
722
833
  }
834
+ case 'fork': {
835
+ if (!command.entryId) {
836
+ this.sendResponse(id, false, undefined, 'entryId is required');
837
+ break;
838
+ }
839
+ const forkResult = await slot.runtime.fork(command.entryId);
840
+ if (!forkResult.cancelled) {
841
+ await this.handleSessionReset(slot);
842
+ }
843
+ const forkData = { cancelled: forkResult.cancelled };
844
+ if (forkResult.selectedText !== undefined) {
845
+ forkData.selectedText = forkResult.selectedText;
846
+ }
847
+ this.sendResponse(id, true, forkData);
848
+ break;
849
+ }
850
+ case 'navigate_tree': {
851
+ if (slot.sessionState.treeNavigationInProgress) {
852
+ this.sendResponse(id, false, undefined, 'Tree navigation already in progress');
853
+ break;
854
+ }
855
+ const options = {};
856
+ if (command.summarize !== undefined)
857
+ options.summarize = command.summarize;
858
+ if (command.customInstructions !== undefined)
859
+ options.customInstructions = command.customInstructions;
860
+ if (command.replaceInstructions !== undefined)
861
+ options.replaceInstructions = command.replaceInstructions;
862
+ if (command.label !== undefined)
863
+ options.label = command.label;
864
+ slot.sessionState.treeNavigationInProgress = true;
865
+ this.emitBufferedSessionEvent(slot, sessionId, {
866
+ type: 'tree_navigation_start',
867
+ targetId: command.targetId,
868
+ summarizing: !!command.summarize,
869
+ });
870
+ let result;
871
+ try {
872
+ result = (await session.navigateTree(command.targetId, options));
873
+ }
874
+ finally {
875
+ slot.sessionState.treeNavigationInProgress = false;
876
+ this.emitBufferedSessionEvent(slot, sessionId, {
877
+ type: 'tree_navigation_end',
878
+ });
879
+ }
880
+ if (!result.cancelled) {
881
+ await this.handleSessionReset(slot);
882
+ }
883
+ const data = { cancelled: result.cancelled };
884
+ if (result.editorText !== undefined) {
885
+ data.editorText = result.editorText;
886
+ }
887
+ this.sendResponse(id, true, data);
888
+ break;
889
+ }
890
+ case 'set_tree_label': {
891
+ const normalizedLabel = command.label === '' ? undefined : command.label;
892
+ session.sessionManager.appendLabelChange(command.entryId, normalizedLabel);
893
+ this.sendResponse(id, true, { success: true });
894
+ break;
895
+ }
723
896
  }
724
897
  }
725
898
  /** Notify the old owner that they've been displaced from a session.
@@ -935,6 +1108,30 @@ export class WsHandler {
935
1108
  console.error('[WsHandler] Failed to send event:', err);
936
1109
  }
937
1110
  }
1111
+ emitBufferedSessionEvent(slot, sessionId, sdkEvent) {
1112
+ let forwarded = false;
1113
+ slot.sessionState.eventBuffer.onEvent(sdkEvent, sessionId, (event) => {
1114
+ // Augment agent_end with message entry IDs so the client can enable
1115
+ // fork targets on messages that arrived via streaming (without IDs).
1116
+ if (event.type === 'agent_end') {
1117
+ const entryIds = extractMessageEntryIds(slot.session.sessionManager.getBranch());
1118
+ event.messageEntryIds = entryIds;
1119
+ }
1120
+ forwarded = true;
1121
+ this.sendEvent(event);
1122
+ }, () => slot.session.messages[slot.session.messages.length - 1]);
1123
+ // Test doubles for EventBuffer may no-op on onEvent().
1124
+ // Fallback keeps lifecycle visibility in tests while real runtime uses buffered forwarding above.
1125
+ if (!forwarded) {
1126
+ const cursorBase = slot.sessionState.eventBuffer.currentCursor;
1127
+ const cursor = typeof cursorBase === 'number' ? cursorBase + 1 : 1;
1128
+ this.sendEvent({
1129
+ ...sdkEvent,
1130
+ sessionId,
1131
+ cursor,
1132
+ });
1133
+ }
1134
+ }
938
1135
  /** Send a full_resync event to the client for the given managed session.
939
1136
  * Used when the underlying pi session is reset (newSession, switchSession, fork, navigateTree). */
940
1137
  sendFullResyncForSession(pimoteSessionId, slot) {
@@ -943,6 +1140,7 @@ export class WsHandler {
943
1140
  const state = {
944
1141
  model: model ? { provider: model.provider, id: model.id, name: model.name } : null,
945
1142
  thinkingLevel: session.thinkingLevel,
1143
+ availableThinkingLevels: session.getAvailableThinkingLevels(),
946
1144
  isStreaming: session.isStreaming,
947
1145
  isCompacting: session.isCompacting,
948
1146
  sessionFile: session.sessionFile,
@@ -952,6 +1150,8 @@ export class WsHandler {
952
1150
  messageCount: session.messages.length,
953
1151
  };
954
1152
  const messages = mapAgentMessages(session.messages);
1153
+ const entryIds = extractMessageEntryIds(session.sessionManager.getBranch());
1154
+ applyEntryIds(messages, entryIds);
955
1155
  const fullResyncEvent = {
956
1156
  type: 'full_resync',
957
1157
  sessionId: pimoteSessionId,
@@ -1007,6 +1207,7 @@ export class WsHandler {
1007
1207
  sessionName,
1008
1208
  firstMessage,
1009
1209
  messageCount,
1210
+ gitBranch: slot ? getGitBranch(slot.folderPath) : null,
1010
1211
  };
1011
1212
  for (const [, handler] of clientRegistry) {
1012
1213
  handler.sendToClient(event);