@pi-chamber/web 0.3.0 → 0.3.2

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 (65) hide show
  1. package/bin/lib/cli-startup.js +53 -10
  2. package/bin/lib/cli-startup.test.js +42 -0
  3. package/dist/assets/{CommitSection-BpbMC0et.js → CommitSection-DLIvjYbM.js} +1 -1
  4. package/dist/assets/{DiagramEditor-dFTMKHuO.js → DiagramEditor-Tw3ZWHML.js} +1 -1
  5. package/dist/assets/{DiagramView-DVw_8Px1.js → DiagramView-CK6R7taL.js} +1 -1
  6. package/dist/assets/{DiffView-CkoY1oAM.js → DiffView-vrxi5oWp.js} +2 -2
  7. package/dist/assets/{FilesView-C0hyg2pa.js → FilesView-BD_h3EVr.js} +6 -6
  8. package/dist/assets/{GitView-D36hpDsb.js → GitView-BwNKqK0K.js} +4 -4
  9. package/dist/assets/{JsonTreeView-BF8S-bPN.js → JsonTreeView-CmY7QgEX.js} +1 -1
  10. package/dist/assets/{MarkdownRendererImpl-BVNChdig.js → MarkdownRendererImpl-CpI-25Xk.js} +1 -1
  11. package/dist/assets/{PatchDiff-B--OaNB1.js → PatchDiff-CrIWbYj7.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-0Qkp9ICt.js → PierreDiffViewer-B410mdbq.js} +2 -2
  13. package/dist/assets/{SessionSwitcherDropdown-cFWgy1td.js → SessionSwitcherDropdown-DROjbyIu.js} +100 -100
  14. package/dist/assets/SettingsView-CsJItYZh.js +4 -0
  15. package/dist/assets/{SettingsWindow-BuJCvYoR.js → SettingsWindow-C9s4AX72.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-Bi9BoEe7.js → ToolOutputDialog-b_R4tAZu.js} +5 -5
  17. package/dist/assets/{ToolPartDiffPreview-DGX7N4zr.js → ToolPartDiffPreview-CfnliaE9.js} +1 -1
  18. package/dist/assets/{VirtualizedFileDiff-CxraYoDZ.js → VirtualizedFileDiff-CQz2fSZI.js} +4 -4
  19. package/dist/assets/{collapsible-d4-HChej.js → collapsible-CR4U9tjw.js} +1 -1
  20. package/dist/assets/{computeVirtualFileMetrics-BuD14qFL.js → computeVirtualFileMetrics-C7vXPUYT.js} +2 -2
  21. package/dist/assets/{contextFileOpenGuard-DirV-hCs.js → contextFileOpenGuard-DtTHRnq1.js} +1 -1
  22. package/dist/assets/{index-DEYblXCW.js → index-BAqpUCjs.js} +5 -5
  23. package/dist/assets/{index-BWmmf5yJ.js → index-DZG82DE1.js} +1 -1
  24. package/dist/assets/{index-DLKBsLKY.css → index-riABQmQ_.css} +1 -1
  25. package/dist/assets/{isDiffPlainText-LoMmNzmX.js → isDiffPlainText-zTq-4A04.js} +1 -1
  26. package/dist/assets/{isFilePlainText-3Hj9i02t.js → isFilePlainText-CLgIdzE5.js} +1 -1
  27. package/dist/assets/{main-BsszlAdQ.js → main-BVWtJYSk.js} +2 -2
  28. package/dist/assets/main-DXYHsqzA.js +133 -0
  29. package/dist/assets/{markdownCore-sGxk8jrc.js → markdownCore-CN0pApQE.js} +2 -2
  30. package/dist/assets/{miniChat-C6SDQL4G.js → miniChat-BrbRq5NQ.js} +2 -2
  31. package/dist/assets/{mobile-DBW3XDmZ.js → mobile-DTPtCn5N.js} +2 -2
  32. package/dist/assets/{renderElectronMiniChatApp-B1_fTdN-.js → renderElectronMiniChatApp-CM8qBaye.js} +1 -1
  33. package/dist/assets/{renderMobileApp-CFxZc_1a.js → renderMobileApp-KS_pt-qc.js} +4 -4
  34. package/dist/assets/{runtimeSurface-9aJdtQbh.js → runtimeSurface-CkHYYg0E.js} +1 -1
  35. package/dist/assets/{useInlineCommentController-Cl--s6vE.js → useInlineCommentController-CwNNjgc7.js} +2 -2
  36. package/dist/assets/usePanelSlide-DkorgQJ5.js +56 -0
  37. package/dist/assets/{useWindowTitle-C-AZD713.js → useWindowTitle-B9wt_3ZQ.js} +3 -3
  38. package/dist/assets/{vendor-codemirror-language-data-HC9JT4I3.js → vendor-codemirror-language-data-x89A7Wy0.js} +2 -2
  39. package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
  40. package/dist/assets/{web-components-skJ_VpnT.js → web-components-BEPfE4kU.js} +2 -2
  41. package/dist/index.html +4 -4
  42. package/dist/mini-chat.html +3 -3
  43. package/dist/mobile.html +3 -3
  44. package/package.json +1 -1
  45. package/server/index.js +33 -0
  46. package/server/lib/pi/routes.js +24 -2
  47. package/server/lib/pi/routes.test.js +14 -6
  48. package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
  49. package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
  50. package/server/lib/pi/session-daemon/session-daemon.js +176 -46
  51. package/server/lib/pi/session-daemon/session-daemon.test.js +364 -6
  52. package/server/lib/pi/session-daemon/supervisor.js +20 -1
  53. package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
  54. package/server/lib/pi/settings-store.js +17 -0
  55. package/server/lib/pi/settings-store.test.js +33 -0
  56. package/server/lib/server/core-routes.js +36 -4
  57. package/server/lib/server/tunnel-service.DOCUMENTATION.md +74 -0
  58. package/server/lib/server/tunnel-service.js +228 -0
  59. package/server/lib/server/tunnel-service.test.js +61 -0
  60. package/server/lib/ui-auth/DOCUMENTATION.md +2 -0
  61. package/server/lib/ui-auth/ui-auth.js +5 -1
  62. package/server/lib/ui-auth/ui-auth.test.js +17 -0
  63. package/dist/assets/SettingsView-D3ZZyjU3.js +0 -4
  64. package/dist/assets/main-CUvZIN16.js +0 -133
  65. package/dist/assets/usePanelSlide-D5ffPTGM.js +0 -56
@@ -0,0 +1,100 @@
1
+ const scopeKey = (cwd, sessionId) => JSON.stringify([cwd, sessionId]);
2
+
3
+ const stripPartSuffix = (messageId) => {
4
+ const suffixIndex = messageId.indexOf(':');
5
+ return suffixIndex === -1 ? messageId : messageId.slice(0, suffixIndex);
6
+ };
7
+
8
+ const findPersistedMessageEntry = (sessionManager, message) => {
9
+ const entries = sessionManager?.getEntries?.();
10
+ if (!Array.isArray(entries)) return undefined;
11
+ return entries.find((entry) => entry?.type === 'message' && entry.message === message);
12
+ };
13
+
14
+ /**
15
+ * Keeps daemon-published live message ids resolvable after Pi persists those
16
+ * messages under its own session-entry ids.
17
+ */
18
+ export function createMessageEntryAliases({ scheduleMicrotask = queueMicrotask } = {}) {
19
+ const aliasesByScope = new Map();
20
+ const pendingByMessage = new WeakMap();
21
+
22
+ const getScope = (cwd, sessionId, create = false) => {
23
+ const key = scopeKey(cwd, sessionId);
24
+ let scope = aliasesByScope.get(key);
25
+ if (!scope && create) {
26
+ scope = new Map();
27
+ aliasesByScope.set(key, scope);
28
+ }
29
+ return scope;
30
+ };
31
+
32
+ const retain = ({ cwd, sessionId, syntheticMessageId, message }) => {
33
+ if (typeof cwd !== 'string' || typeof sessionId !== 'string' || typeof syntheticMessageId !== 'string'
34
+ || !message || typeof message !== 'object') return;
35
+ const alias = { message };
36
+ getScope(cwd, sessionId, true).set(syntheticMessageId, alias);
37
+ pendingByMessage.set(message, { alias });
38
+ };
39
+
40
+ const releasePersisted = (pending, sessionManager) => {
41
+ const entry = findPersistedMessageEntry(sessionManager, pending.alias.message);
42
+ if (!entry || typeof entry.id !== 'string' || entry.id.length === 0) return undefined;
43
+ pending.alias.entryId = entry.id;
44
+ delete pending.alias.message;
45
+ pendingByMessage.delete(entry.message);
46
+ return entry.id;
47
+ };
48
+
49
+ return {
50
+ retain,
51
+
52
+ observeMessageEnd({ cwd, sessionId, syntheticMessageId, message, sessionManager }) {
53
+ if (typeof syntheticMessageId !== 'string' || !message || typeof message !== 'object') return;
54
+ const alias = getScope(cwd, sessionId)?.get(syntheticMessageId);
55
+ if (!alias) return;
56
+ if (alias.message && alias.message !== message) pendingByMessage.delete(alias.message);
57
+ alias.message = message;
58
+ const pending = { alias };
59
+ pendingByMessage.set(message, pending);
60
+ scheduleMicrotask(() => {
61
+ releasePersisted(pending, sessionManager);
62
+ });
63
+ },
64
+
65
+ resolve({ cwd, sessionId, requestedId, sessionManager }) {
66
+ if (typeof requestedId !== 'string' || requestedId.length === 0) return requestedId;
67
+ const entryId = stripPartSuffix(requestedId);
68
+ if (sessionManager?.getEntry?.(entryId)) return entryId;
69
+
70
+ const alias = getScope(cwd, sessionId)?.get(entryId);
71
+ if (!alias) return entryId;
72
+ if (typeof alias.entryId === 'string' && alias.entryId.length > 0) return alias.entryId;
73
+ if (alias.message) {
74
+ const pending = pendingByMessage.get(alias.message);
75
+ const persistedEntryId = pending ? releasePersisted(pending, sessionManager) : undefined;
76
+ if (persistedEntryId) return persistedEntryId;
77
+ }
78
+ return entryId;
79
+ },
80
+
81
+ clearSession({ cwd, sessionId }) {
82
+ const key = scopeKey(cwd, sessionId);
83
+ const scope = aliasesByScope.get(key);
84
+ if (!scope) return;
85
+ for (const alias of scope.values()) {
86
+ if (alias.message) pendingByMessage.delete(alias.message);
87
+ }
88
+ aliasesByScope.delete(key);
89
+ },
90
+
91
+ clear() {
92
+ for (const scope of aliasesByScope.values()) {
93
+ for (const alias of scope.values()) {
94
+ if (alias.message) pendingByMessage.delete(alias.message);
95
+ }
96
+ }
97
+ aliasesByScope.clear();
98
+ },
99
+ };
100
+ }
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  import { createPiModelConfigStore } from '../model-config-store.js';
20
20
  import { clampThinkingLevel, getSupportedThinkingLevels, isPiThinkingLevel } from '../thinking-levels.js';
21
+ import { createMessageEntryAliases } from './message-entry-aliases.js';
21
22
  import { createSessionRuntimeRegistry } from './runtime-registry.js';
22
23
  import {
23
24
  findPiSessionJsonlById,
@@ -26,6 +27,7 @@ import {
26
27
  validatePiSessionJsonlDirectory,
27
28
  validatePiSessionJsonlFile,
28
29
  } from './session-jsonl.js';
30
+ import { resolvePiChamberDataDir } from '../pichamber-data-dir.js';
29
31
 
30
32
  const PROTOCOL_VERSION = 1;
31
33
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
@@ -136,9 +138,12 @@ export function createSessionDaemon({
136
138
  const messageStartedAt = new Map();
137
139
  const toolStartedAt = new Map();
138
140
  const latestUserMessageIds = new Map();
141
+ const retryStateBySession = new Map();
142
+ const activeRunStartedAt = new Map();
139
143
  const sendGenerationBySession = new Map();
140
144
  const streamingRedactionBuffers = new Map();
141
145
  const loginAttempts = new Map();
146
+ const messageEntryAliases = createMessageEntryAliases();
142
147
  const MAX_REPLAY_EVENTS = 1_024;
143
148
 
144
149
  const validateDirectoryPath = async (dir) => {
@@ -238,6 +243,7 @@ export function createSessionDaemon({
238
243
  : getSessionState();
239
244
  if (requestedSessionId && requestedSessionId !== session.sessionId) return;
240
245
  const activeSession = targetRuntime?.session || runtime?.session;
246
+ const retry = session.sessionId ? retryStateBySession.get(session.sessionId) : undefined;
241
247
  const targetDirectory = targetRuntime?.cwd || activeDirectory || cwd;
242
248
  const messages = activeSession ? projectMessageEntries(activeSession, targetDirectory) : [];
243
249
  const lastAssistant = [...messages].reverse().find((entry) => entry.message.role === 'assistant')?.message;
@@ -252,7 +258,8 @@ export function createSessionDaemon({
252
258
  ...(session.sessionId ? { sessionId: session.sessionId } : {}),
253
259
  directory: targetDirectory,
254
260
  isStreaming: session.isStreaming ?? false,
255
- lifecycle: session.isStreaming ? 'busy' : 'idle',
261
+ lifecycle: retry ? 'retry' : session.isStreaming ? 'busy' : 'idle',
262
+ ...(retry ? { retry } : {}),
256
263
  queue: activeSession ? {
257
264
  steering: activeSession.getSteeringMessages?.().length ?? 0,
258
265
  followUp: activeSession.getFollowUpMessages?.().length ?? 0,
@@ -261,6 +268,8 @@ export function createSessionDaemon({
261
268
  ...(activeSession?.thinkingLevel ? { thinking: activeSession.thinkingLevel } : {}),
262
269
  ...(typeof lastAssistant?.text === 'string' ? { lastText: lastAssistant.text } : {}),
263
270
  ...(typeof lastAssistant?.thinking === 'string' ? { lastThinking: lastAssistant.thinking } : {}),
271
+ ...(session.sessionId && activeRunStartedAt.has(session.sessionId) ? { runStartedAt: activeRunStartedAt.get(session.sessionId) } : {}),
272
+ serverNow: Date.now(),
264
273
  lastSequence: snapshotSequence,
265
274
  },
266
275
  });
@@ -727,6 +736,7 @@ export function createSessionDaemon({
727
736
  ?? (model?.provider && model?.id ? { providerId: model.provider, modelId: model.id } : undefined);
728
737
  const sessionThinking = lastAssistant?.thinkingLevel || session.thinkingLevel;
729
738
  const isStreaming = session.isStreaming === true;
739
+ const retry = retryStateBySession.get(session.sessionId);
730
740
  return {
731
741
  session: {
732
742
  id: session.sessionId, directory: targetDir, createdAt, updatedAt: createdAt,
@@ -738,11 +748,18 @@ export function createSessionDaemon({
738
748
  messages,
739
749
  lastSequence: sequence,
740
750
  isStreaming,
741
- lifecycle: isStreaming ? 'busy' : 'idle',
751
+ lifecycle: retry ? 'retry' : isStreaming ? 'busy' : 'idle',
752
+ ...(retry ? { retry } : {}),
753
+ ...(activeRunStartedAt.has(session.sessionId) ? { runStartedAt: activeRunStartedAt.get(session.sessionId) } : {}),
754
+ serverNow: Date.now(),
742
755
  };
743
756
  };
744
757
 
745
758
  const createSession = async (payload) => {
759
+ const explicitRetryLimit = payload && typeof payload === 'object' ? (payload.maxRetries ?? payload.retryLimit ?? payload.defaultRetryLimit) : undefined;
760
+ if (explicitRetryLimit !== undefined && (!Number.isInteger(explicitRetryLimit) || explicitRetryLimit < 0 || explicitRetryLimit > 10)) {
761
+ throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The retry limit must be an integer between 0 and 10.');
762
+ }
746
763
  if (!payload || typeof payload !== 'object'
747
764
  || (payload.cwd !== undefined && (typeof payload.cwd !== 'string' || payload.cwd.length === 0))
748
765
  || (payload.title !== undefined && (typeof payload.title !== 'string' || payload.title.trim().length === 0 || payload.title.length > 256))
@@ -764,6 +781,32 @@ export function createSessionDaemon({
764
781
  ...(parent ? { sessionFile: parent.path } : {}),
765
782
  });
766
783
  if (!newRuntime.cwd) newRuntime.cwd = targetCwd;
784
+ // Apply default retry limit for new sessions. Explicit per-run overrides win.
785
+ try {
786
+ const settingsManager = newRuntime.services?.settingsManager;
787
+ if (settingsManager && typeof settingsManager.getRetrySettings === 'function') {
788
+ let effective = explicitRetryLimit;
789
+ if (effective === undefined) {
790
+ try {
791
+ const raw = await readFile(join(resolvePiChamberDataDir(), 'pi', 'settings.json'), 'utf8');
792
+ const parsed = JSON.parse(raw);
793
+ if (Number.isInteger(parsed?.defaultRetryLimit)) effective = parsed.defaultRetryLimit;
794
+ } catch {}
795
+ if (effective === undefined) effective = 3;
796
+ }
797
+ if (Number.isInteger(effective) && effective >= 0 && effective <= 10) {
798
+ const current = settingsManager.getRetrySettings().maxRetries;
799
+ if (current !== effective) {
800
+ if (typeof settingsManager.applyOverrides === 'function') {
801
+ settingsManager.applyOverrides({ retry: { maxRetries: effective } });
802
+ if (explicitRetryLimit === undefined) await settingsManager.flush().catch(() => {});
803
+ } else if (settingsManager.globalSettings) {
804
+ settingsManager.globalSettings.retry = { ...(settingsManager.globalSettings.retry ?? {}), maxRetries: effective };
805
+ }
806
+ }
807
+ }
808
+ }
809
+ } catch {}
767
810
  let result = { cancelled: false };
768
811
  if (typeof newRuntime.newSession === 'function') {
769
812
  result = await newRuntime.newSession({
@@ -1349,11 +1392,72 @@ export function createSessionDaemon({
1349
1392
  }
1350
1393
  };
1351
1394
 
1352
- const redactAttachmentPaths = (text) => typeof text === 'string'
1353
- ? text
1354
- .replace(/\[Attachment[^\]\r\n]*pi-clipboard-[0-9a-f-]{36}[^\]\r\n]*\]/gi, '[attachment]')
1355
- .replace(/(?:[A-Za-z]:)?[^\s[\](){}"'`]*pi-clipboard-[0-9a-f-]{36}(?:\.[^\s\])}\]"'`,;]+)?/gi, '[attachment]')
1356
- : '';
1395
+ const attachmentMarkerPattern = /pi-clipboard-/i;
1396
+ const attachmentMarkerSearchPattern = /pi-clipboard-/gi;
1397
+ const attachmentIdPattern = /^[0-9a-f-]{36}$/i;
1398
+ const attachmentBracketStartPattern = /\[Attachment/gi;
1399
+ const attachmentTokenPattern = /pi-clipboard-[0-9a-f-]{36}/i;
1400
+ const isAttachmentPathDelimiter = (character) => /[\s[\](){}"'`,;]/u.test(character);
1401
+
1402
+ const redactAttachmentBrackets = (text) => {
1403
+ let cursor = 0;
1404
+ let output = '';
1405
+ while (cursor < text.length) {
1406
+ attachmentBracketStartPattern.lastIndex = cursor;
1407
+ const bracketStart = attachmentBracketStartPattern.exec(text);
1408
+ if (!bracketStart) {
1409
+ output += text.slice(cursor);
1410
+ break;
1411
+ }
1412
+
1413
+ output += text.slice(cursor, bracketStart.index);
1414
+ let bracketEnd = bracketStart.index + bracketStart[0].length;
1415
+ while (bracketEnd < text.length && text[bracketEnd] !== ']' && text[bracketEnd] !== '\r' && text[bracketEnd] !== '\n') bracketEnd += 1;
1416
+ if (text[bracketEnd] === ']') {
1417
+ const candidate = text.slice(bracketStart.index, bracketEnd + 1);
1418
+ output += attachmentTokenPattern.test(candidate) ? '[attachment]' : candidate;
1419
+ cursor = bracketEnd + 1;
1420
+ } else {
1421
+ output += text.slice(bracketStart.index, bracketEnd);
1422
+ cursor = bracketEnd;
1423
+ }
1424
+ }
1425
+ return output;
1426
+ };
1427
+
1428
+ const redactAttachmentPaths = (text) => {
1429
+ if (typeof text !== 'string') return '';
1430
+
1431
+ if (!attachmentMarkerPattern.test(text)) return text;
1432
+ const bracketRedacted = redactAttachmentBrackets(text);
1433
+ let cursor = 0;
1434
+ let output = '';
1435
+ while (cursor < bracketRedacted.length) {
1436
+ attachmentMarkerSearchPattern.lastIndex = cursor;
1437
+ const markerMatch = attachmentMarkerSearchPattern.exec(bracketRedacted);
1438
+ if (!markerMatch) {
1439
+ output += bracketRedacted.slice(cursor);
1440
+ break;
1441
+ }
1442
+
1443
+ const markerIndex = markerMatch.index;
1444
+ const idStart = markerIndex + markerMatch[0].length;
1445
+ const idEnd = idStart + 36;
1446
+ if (!attachmentIdPattern.test(bracketRedacted.slice(idStart, idEnd))) {
1447
+ output += bracketRedacted.slice(cursor, idStart);
1448
+ cursor = idStart;
1449
+ continue;
1450
+ }
1451
+
1452
+ let tokenStart = markerIndex;
1453
+ while (tokenStart > cursor && !isAttachmentPathDelimiter(bracketRedacted[tokenStart - 1])) tokenStart -= 1;
1454
+ let tokenEnd = idEnd;
1455
+ while (tokenEnd < bracketRedacted.length && !isAttachmentPathDelimiter(bracketRedacted[tokenEnd])) tokenEnd += 1;
1456
+ output += `${bracketRedacted.slice(cursor, tokenStart)}[attachment]`;
1457
+ cursor = tokenEnd;
1458
+ }
1459
+ return output;
1460
+ };
1357
1461
 
1358
1462
  const redactAttachmentValues = (value) => {
1359
1463
  if (typeof value === 'string') return redactAttachmentPaths(value);
@@ -1602,7 +1706,12 @@ export function createSessionDaemon({
1602
1706
  targetDir = directory;
1603
1707
  await rm(target.path, { force: false });
1604
1708
  }
1605
- publish('session.lifecycle', { state: 'idle', deleted: true }, sessionId, targetDir);
1709
+ messageEntryAliases.clearSession({ cwd: active?.cwd || targetDir, sessionId });
1710
+ retryStateBySession.delete(sessionId);
1711
+ activeRunStartedAt.delete(sessionId);
1712
+ latestUserMessageIds.delete(sessionId);
1713
+ latestAssistantMessageIds.delete(sessionId);
1714
+ publish('session.lifecycle', { state: 'idle', deleted: true, serverNow: Date.now() }, sessionId, targetDir);
1606
1715
  };
1607
1716
 
1608
1717
  const publishSessionEvent = (sessionId, event, directory = activeDirectory || cwd) => {
@@ -1616,6 +1725,7 @@ export function createSessionDaemon({
1616
1725
  ? content.filter((part) => part?.type === 'text').map((part) => part.text).join('')
1617
1726
  : '');
1618
1727
  const messageId = `user-${sessionId}-${sequence + 1}`;
1728
+ messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
1619
1729
  latestUserMessageIds.set(sessionId, messageId);
1620
1730
  publish('assistant.message.start', {
1621
1731
  messageId,
@@ -1625,6 +1735,7 @@ export function createSessionDaemon({
1625
1735
  }, sessionId, directory);
1626
1736
  } else if (event.message?.role === 'assistant') {
1627
1737
  const messageId = `assistant-${sessionId}-${sequence + 1}`;
1738
+ messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
1628
1739
  clearStreamingRedactionBuffers(sessionId);
1629
1740
  streamingMessageIds.set(sessionId, messageId);
1630
1741
  latestAssistantMessageIds.set(sessionId, messageId);
@@ -1653,6 +1764,19 @@ export function createSessionDaemon({
1653
1764
  break;
1654
1765
  }
1655
1766
  case 'message_end': {
1767
+ const eventRuntime = runtimeRegistry?.get({ cwd: directory, sessionId });
1768
+ const syntheticMessageId = event.message?.role === 'assistant'
1769
+ ? streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId)
1770
+ : event.message?.role === 'user'
1771
+ ? latestUserMessageIds.get(sessionId)
1772
+ : undefined;
1773
+ messageEntryAliases.observeMessageEnd({
1774
+ cwd: directory,
1775
+ sessionId,
1776
+ syntheticMessageId,
1777
+ message: event.message,
1778
+ sessionManager: eventRuntime?.session?.sessionManager,
1779
+ });
1656
1780
  if (event.message?.role === 'assistant') {
1657
1781
  const content = Array.isArray(event.message.content) ? event.message.content : [];
1658
1782
  const messageId = streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId) ?? `assistant-${sessionId}`;
@@ -1665,6 +1789,7 @@ export function createSessionDaemon({
1665
1789
  text: redactAttachmentPaths(content.filter((part) => part?.type === 'text').map((part) => part.text).join('')),
1666
1790
  thinking: redactAttachmentPaths(content.filter((part) => part?.type === 'thinking').map((part) => part.thinking).join('')),
1667
1791
  durationMs,
1792
+ ...(content.some((part) => part?.type === 'toolCall') ? { continuing: true } : {}),
1668
1793
  ...(event.message.errorMessage ? { error: { code: 'ASSISTANT_ERROR', message: redactAttachmentPaths(event.message.errorMessage) } } : {}),
1669
1794
  ...(usage ? { usage } : {}),
1670
1795
  }, sessionId, directory);
@@ -1728,11 +1853,11 @@ export function createSessionDaemon({
1728
1853
  break;
1729
1854
  case 'agent_start':
1730
1855
  clearIdleDisposal(sessionId);
1731
- publish('session.lifecycle', { state: 'busy' }, sessionId, directory);
1856
+ retryStateBySession.delete(sessionId);
1857
+ if (!activeRunStartedAt.has(sessionId)) activeRunStartedAt.set(sessionId, Date.now());
1858
+ publish('session.lifecycle', { state: 'busy', runStartedAt: activeRunStartedAt.get(sessionId), serverNow: Date.now() }, sessionId, directory);
1732
1859
  break;
1733
1860
  case 'agent_end': {
1734
- latestUserMessageIds.delete(sessionId);
1735
- latestAssistantMessageIds.delete(sessionId);
1736
1861
  const finalMessage = event.messages?.at?.(-1);
1737
1862
  if (finalMessage?.role === 'assistant' && finalMessage.stopReason === 'aborted') {
1738
1863
  publish('session.interrupted', { reason: 'user-abort', streaming: false }, sessionId, directory);
@@ -1741,16 +1866,26 @@ export function createSessionDaemon({
1741
1866
  }
1742
1867
  break;
1743
1868
  }
1744
- case 'auto_retry_start':
1745
- publish('session.lifecycle', {
1746
- state: 'retry',
1869
+ case 'auto_retry_start': {
1870
+ const retry = {
1747
1871
  attempt: event.attempt,
1748
1872
  next: Date.now() + event.delayMs,
1749
1873
  message: redactAttachmentPaths(event.errorMessage),
1750
- }, sessionId, directory);
1874
+ };
1875
+ retryStateBySession.set(sessionId, retry);
1876
+ if (!activeRunStartedAt.has(sessionId)) activeRunStartedAt.set(sessionId, Date.now());
1877
+ publish('session.lifecycle', { state: 'retry', ...retry, runStartedAt: activeRunStartedAt.get(sessionId), serverNow: Date.now() }, sessionId, directory);
1878
+ break;
1879
+ }
1880
+ case 'auto_retry_end':
1881
+ retryStateBySession.delete(sessionId);
1751
1882
  break;
1752
1883
  case 'agent_settled':
1753
- publish('session.lifecycle', { state: 'idle' }, sessionId, directory);
1884
+ retryStateBySession.delete(sessionId);
1885
+ activeRunStartedAt.delete(sessionId);
1886
+ latestUserMessageIds.delete(sessionId);
1887
+ latestAssistantMessageIds.delete(sessionId);
1888
+ publish('session.lifecycle', { state: 'idle', serverNow: Date.now() }, sessionId, directory);
1754
1889
  scheduleIdleDisposal(sessionId);
1755
1890
  break;
1756
1891
  case 'thinking_level_changed':
@@ -1924,35 +2059,16 @@ export function createSessionDaemon({
1924
2059
  }
1925
2060
  case 'sessions.navigate': {
1926
2061
  const activeRuntime = await activateSession(message.payload?.sessionId, message.payload?.directory || message.payload?.cwd);
1927
- let messageId = message.payload?.messageId;
1928
- if (typeof messageId !== 'string' || messageId.length === 0) throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested tree entry is invalid.');
1929
- // UI may pass a part id like "c865141a:text:1" (e.g. from a tool block).
1930
- // Pi only knows entry ids, so strip the suffix and retry the entry lookup.
1931
- const tryIds = [messageId];
1932
- if (messageId.includes(':')) {
1933
- const entryId = messageId.split(':')[0];
1934
- if (entryId && entryId !== messageId) tryIds.unshift(entryId);
1935
- }
2062
+ const requestedId = message.payload?.messageId;
2063
+ if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested tree entry is invalid.');
2064
+ const messageId = messageEntryAliases.resolve({
2065
+ cwd: activeRuntime.cwd,
2066
+ sessionId: message.payload.sessionId,
2067
+ requestedId,
2068
+ sessionManager: activeRuntime.session.sessionManager,
2069
+ });
1936
2070
  const previousLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
1937
- let result;
1938
- let lastError;
1939
- for (const tryId of tryIds) {
1940
- try {
1941
- result = await activeRuntime.session.navigateTree(tryId);
1942
- messageId = tryId;
1943
- lastError = undefined;
1944
- break;
1945
- } catch (error) {
1946
- lastError = error;
1947
- // Only retry on "Entry not found" with a stripped id; otherwise surface immediately.
1948
- const isEntryNotFound = String(error?.message ?? '').includes('not found');
1949
- if (!isEntryNotFound || tryId === tryIds[tryIds.length - 1]) {
1950
- console.error(`[pi:navigate] target=${tryId} leaf=${previousLeafId} branch=${activeRuntime.session.sessionManager?.getBranch?.()?.map((e) => e.id).join(',') ?? 'unknown'} error=${error?.message ?? String(error)}`);
1951
- throw error;
1952
- }
1953
- }
1954
- }
1955
- if (lastError) throw lastError;
2071
+ const result = await activeRuntime.session.navigateTree(messageId);
1956
2072
  if (result?.cancelled) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'Pi cancelled tree navigation.');
1957
2073
  const newLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
1958
2074
  const navigation = {
@@ -1967,8 +2083,16 @@ export function createSessionDaemon({
1967
2083
  case 'sessions.fork':
1968
2084
  case 'sessions.clone': {
1969
2085
  const activeRuntime = await activateSession(message.payload?.sessionId, message.payload?.directory || message.payload?.cwd);
1970
- const entryId = message.command === 'sessions.fork' ? message.payload?.messageId : activeRuntime.session.sessionManager?.getLeafId?.();
1971
- if (typeof entryId !== 'string' || entryId.length === 0) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'The Pi session has no fork point.');
2086
+ const requestedId = message.command === 'sessions.fork' ? message.payload?.messageId : activeRuntime.session.sessionManager?.getLeafId?.();
2087
+ if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'The Pi session has no fork point.');
2088
+ const entryId = message.command === 'sessions.fork'
2089
+ ? messageEntryAliases.resolve({
2090
+ cwd: activeRuntime.cwd,
2091
+ sessionId: message.payload.sessionId,
2092
+ requestedId,
2093
+ sessionManager: activeRuntime.session.sessionManager,
2094
+ })
2095
+ : requestedId;
1972
2096
  const result = await activeRuntime.fork(entryId, { position: 'at' });
1973
2097
  if (result.cancelled) throw new SessionDaemonProtocolError('SESSION_CREATE_CANCELLED', 'Pi cancelled session creation.');
1974
2098
  rememberRuntimeSession();
@@ -2136,6 +2260,7 @@ export function createSessionDaemon({
2136
2260
  if (platform !== 'win32') await chmod(endpoint, 0o600);
2137
2261
  started = true;
2138
2262
  } catch (error) {
2263
+ messageEntryAliases.clear();
2139
2264
  await disposeRuntime();
2140
2265
  server = undefined;
2141
2266
  throw error;
@@ -2149,8 +2274,13 @@ export function createSessionDaemon({
2149
2274
  clearTimeout(attempt.expiry);
2150
2275
  }
2151
2276
  loginAttempts.clear();
2277
+ retryStateBySession.clear();
2278
+ activeRunStartedAt.clear();
2279
+ latestUserMessageIds.clear();
2280
+ latestAssistantMessageIds.clear();
2152
2281
  for (const client of clients) client.destroy();
2153
2282
  clients.clear();
2283
+ messageEntryAliases.clear();
2154
2284
  await new Promise((resolve, reject) => {
2155
2285
  server.close((error) => error ? reject(error) : resolve());
2156
2286
  });