@pi-chamber/web 0.3.0 → 0.3.1

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 (50) hide show
  1. package/dist/assets/{CommitSection-BpbMC0et.js → CommitSection-C336377Z.js} +1 -1
  2. package/dist/assets/{DiagramEditor-dFTMKHuO.js → DiagramEditor-gHLCXHtr.js} +1 -1
  3. package/dist/assets/{DiagramView-DVw_8Px1.js → DiagramView-DlHzRRBc.js} +1 -1
  4. package/dist/assets/{DiffView-CkoY1oAM.js → DiffView-5wHwK-lg.js} +1 -1
  5. package/dist/assets/{FilesView-C0hyg2pa.js → FilesView-5bswI3JZ.js} +1 -1
  6. package/dist/assets/{GitView-D36hpDsb.js → GitView-BfykGpGh.js} +3 -3
  7. package/dist/assets/{JsonTreeView-BF8S-bPN.js → JsonTreeView-HVwQS7qG.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-BVNChdig.js → MarkdownRendererImpl-kjd4e0N8.js} +1 -1
  9. package/dist/assets/{PatchDiff-B--OaNB1.js → PatchDiff-Dlm4RU8n.js} +1 -1
  10. package/dist/assets/{PierreDiffViewer-0Qkp9ICt.js → PierreDiffViewer-St32rXlD.js} +1 -1
  11. package/dist/assets/{SessionSwitcherDropdown-cFWgy1td.js → SessionSwitcherDropdown-D5bWfZNA.js} +92 -92
  12. package/dist/assets/{SettingsView-D3ZZyjU3.js → SettingsView-FGe5TEQq.js} +3 -3
  13. package/dist/assets/{SettingsWindow-BuJCvYoR.js → SettingsWindow-Ck_uhTnx.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-Bi9BoEe7.js → ToolOutputDialog-BCONUv3r.js} +5 -5
  15. package/dist/assets/{ToolPartDiffPreview-DGX7N4zr.js → ToolPartDiffPreview-BdVuk2kc.js} +1 -1
  16. package/dist/assets/{VirtualizedFileDiff-CxraYoDZ.js → VirtualizedFileDiff-BsAZM0jo.js} +1 -1
  17. package/dist/assets/{collapsible-d4-HChej.js → collapsible-Wnmq-13H.js} +1 -1
  18. package/dist/assets/{computeVirtualFileMetrics-BuD14qFL.js → computeVirtualFileMetrics-skbyL2Az.js} +2 -2
  19. package/dist/assets/{contextFileOpenGuard-DirV-hCs.js → contextFileOpenGuard-BsxQITMZ.js} +1 -1
  20. package/dist/assets/{index-DEYblXCW.js → index-8TL2HlaR.js} +4 -4
  21. package/dist/assets/{index-BWmmf5yJ.js → index-Bfp6eYap.js} +1 -1
  22. package/dist/assets/{index-DLKBsLKY.css → index-riABQmQ_.css} +1 -1
  23. package/dist/assets/{isDiffPlainText-LoMmNzmX.js → isDiffPlainText-dZkfk3yG.js} +1 -1
  24. package/dist/assets/{isFilePlainText-3Hj9i02t.js → isFilePlainText-UL_tMQZx.js} +1 -1
  25. package/dist/assets/{main-BsszlAdQ.js → main-CHQfoyRP.js} +2 -2
  26. package/dist/assets/{main-CUvZIN16.js → main-DbnYXZeK.js} +4 -4
  27. package/dist/assets/{markdownCore-sGxk8jrc.js → markdownCore-qX3mJFkL.js} +2 -2
  28. package/dist/assets/{miniChat-C6SDQL4G.js → miniChat-3QtCNwHl.js} +2 -2
  29. package/dist/assets/{mobile-DBW3XDmZ.js → mobile-B_CyfRAX.js} +2 -2
  30. package/dist/assets/{renderElectronMiniChatApp-B1_fTdN-.js → renderElectronMiniChatApp-OToslrv-.js} +1 -1
  31. package/dist/assets/{renderMobileApp-CFxZc_1a.js → renderMobileApp-BD_wep_I.js} +4 -4
  32. package/dist/assets/{runtimeSurface-9aJdtQbh.js → runtimeSurface-CkdOpqo7.js} +1 -1
  33. package/dist/assets/{useInlineCommentController-Cl--s6vE.js → useInlineCommentController-DVif5Fr8.js} +2 -2
  34. package/dist/assets/{usePanelSlide-D5ffPTGM.js → usePanelSlide-C_PJmPXW.js} +2 -2
  35. package/dist/assets/{useWindowTitle-C-AZD713.js → useWindowTitle-BZVlLMCy.js} +1 -1
  36. package/dist/assets/{vendor-codemirror-language-data-HC9JT4I3.js → vendor-codemirror-language-data-C-924Z4o.js} +2 -2
  37. package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
  38. package/dist/assets/{web-components-skJ_VpnT.js → web-components-BG-6PEN4.js} +4 -4
  39. package/dist/index.html +4 -4
  40. package/dist/mini-chat.html +3 -3
  41. package/dist/mobile.html +3 -3
  42. package/package.json +1 -1
  43. package/server/lib/pi/routes.js +20 -2
  44. package/server/lib/pi/routes.test.js +14 -6
  45. package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
  46. package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
  47. package/server/lib/pi/session-daemon/session-daemon.js +66 -38
  48. package/server/lib/pi/session-daemon/session-daemon.test.js +289 -6
  49. package/server/lib/pi/session-daemon/supervisor.js +20 -1
  50. package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
@@ -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,
@@ -136,9 +137,11 @@ export function createSessionDaemon({
136
137
  const messageStartedAt = new Map();
137
138
  const toolStartedAt = new Map();
138
139
  const latestUserMessageIds = new Map();
140
+ const retryStateBySession = new Map();
139
141
  const sendGenerationBySession = new Map();
140
142
  const streamingRedactionBuffers = new Map();
141
143
  const loginAttempts = new Map();
144
+ const messageEntryAliases = createMessageEntryAliases();
142
145
  const MAX_REPLAY_EVENTS = 1_024;
143
146
 
144
147
  const validateDirectoryPath = async (dir) => {
@@ -238,6 +241,7 @@ export function createSessionDaemon({
238
241
  : getSessionState();
239
242
  if (requestedSessionId && requestedSessionId !== session.sessionId) return;
240
243
  const activeSession = targetRuntime?.session || runtime?.session;
244
+ const retry = session.sessionId ? retryStateBySession.get(session.sessionId) : undefined;
241
245
  const targetDirectory = targetRuntime?.cwd || activeDirectory || cwd;
242
246
  const messages = activeSession ? projectMessageEntries(activeSession, targetDirectory) : [];
243
247
  const lastAssistant = [...messages].reverse().find((entry) => entry.message.role === 'assistant')?.message;
@@ -252,7 +256,8 @@ export function createSessionDaemon({
252
256
  ...(session.sessionId ? { sessionId: session.sessionId } : {}),
253
257
  directory: targetDirectory,
254
258
  isStreaming: session.isStreaming ?? false,
255
- lifecycle: session.isStreaming ? 'busy' : 'idle',
259
+ lifecycle: retry ? 'retry' : session.isStreaming ? 'busy' : 'idle',
260
+ ...(retry ? { retry } : {}),
256
261
  queue: activeSession ? {
257
262
  steering: activeSession.getSteeringMessages?.().length ?? 0,
258
263
  followUp: activeSession.getFollowUpMessages?.().length ?? 0,
@@ -727,6 +732,7 @@ export function createSessionDaemon({
727
732
  ?? (model?.provider && model?.id ? { providerId: model.provider, modelId: model.id } : undefined);
728
733
  const sessionThinking = lastAssistant?.thinkingLevel || session.thinkingLevel;
729
734
  const isStreaming = session.isStreaming === true;
735
+ const retry = retryStateBySession.get(session.sessionId);
730
736
  return {
731
737
  session: {
732
738
  id: session.sessionId, directory: targetDir, createdAt, updatedAt: createdAt,
@@ -738,7 +744,8 @@ export function createSessionDaemon({
738
744
  messages,
739
745
  lastSequence: sequence,
740
746
  isStreaming,
741
- lifecycle: isStreaming ? 'busy' : 'idle',
747
+ lifecycle: retry ? 'retry' : isStreaming ? 'busy' : 'idle',
748
+ ...(retry ? { retry } : {}),
742
749
  };
743
750
  };
744
751
 
@@ -1602,6 +1609,10 @@ export function createSessionDaemon({
1602
1609
  targetDir = directory;
1603
1610
  await rm(target.path, { force: false });
1604
1611
  }
1612
+ messageEntryAliases.clearSession({ cwd: active?.cwd || targetDir, sessionId });
1613
+ retryStateBySession.delete(sessionId);
1614
+ latestUserMessageIds.delete(sessionId);
1615
+ latestAssistantMessageIds.delete(sessionId);
1605
1616
  publish('session.lifecycle', { state: 'idle', deleted: true }, sessionId, targetDir);
1606
1617
  };
1607
1618
 
@@ -1616,6 +1627,7 @@ export function createSessionDaemon({
1616
1627
  ? content.filter((part) => part?.type === 'text').map((part) => part.text).join('')
1617
1628
  : '');
1618
1629
  const messageId = `user-${sessionId}-${sequence + 1}`;
1630
+ messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
1619
1631
  latestUserMessageIds.set(sessionId, messageId);
1620
1632
  publish('assistant.message.start', {
1621
1633
  messageId,
@@ -1625,6 +1637,7 @@ export function createSessionDaemon({
1625
1637
  }, sessionId, directory);
1626
1638
  } else if (event.message?.role === 'assistant') {
1627
1639
  const messageId = `assistant-${sessionId}-${sequence + 1}`;
1640
+ messageEntryAliases.retain({ cwd: directory, sessionId, syntheticMessageId: messageId, message: event.message });
1628
1641
  clearStreamingRedactionBuffers(sessionId);
1629
1642
  streamingMessageIds.set(sessionId, messageId);
1630
1643
  latestAssistantMessageIds.set(sessionId, messageId);
@@ -1653,6 +1666,19 @@ export function createSessionDaemon({
1653
1666
  break;
1654
1667
  }
1655
1668
  case 'message_end': {
1669
+ const eventRuntime = runtimeRegistry?.get({ cwd: directory, sessionId });
1670
+ const syntheticMessageId = event.message?.role === 'assistant'
1671
+ ? streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId)
1672
+ : event.message?.role === 'user'
1673
+ ? latestUserMessageIds.get(sessionId)
1674
+ : undefined;
1675
+ messageEntryAliases.observeMessageEnd({
1676
+ cwd: directory,
1677
+ sessionId,
1678
+ syntheticMessageId,
1679
+ message: event.message,
1680
+ sessionManager: eventRuntime?.session?.sessionManager,
1681
+ });
1656
1682
  if (event.message?.role === 'assistant') {
1657
1683
  const content = Array.isArray(event.message.content) ? event.message.content : [];
1658
1684
  const messageId = streamingMessageIds.get(sessionId) ?? latestAssistantMessageIds.get(sessionId) ?? `assistant-${sessionId}`;
@@ -1665,6 +1691,7 @@ export function createSessionDaemon({
1665
1691
  text: redactAttachmentPaths(content.filter((part) => part?.type === 'text').map((part) => part.text).join('')),
1666
1692
  thinking: redactAttachmentPaths(content.filter((part) => part?.type === 'thinking').map((part) => part.thinking).join('')),
1667
1693
  durationMs,
1694
+ ...(content.some((part) => part?.type === 'toolCall') ? { continuing: true } : {}),
1668
1695
  ...(event.message.errorMessage ? { error: { code: 'ASSISTANT_ERROR', message: redactAttachmentPaths(event.message.errorMessage) } } : {}),
1669
1696
  ...(usage ? { usage } : {}),
1670
1697
  }, sessionId, directory);
@@ -1728,11 +1755,10 @@ export function createSessionDaemon({
1728
1755
  break;
1729
1756
  case 'agent_start':
1730
1757
  clearIdleDisposal(sessionId);
1758
+ retryStateBySession.delete(sessionId);
1731
1759
  publish('session.lifecycle', { state: 'busy' }, sessionId, directory);
1732
1760
  break;
1733
1761
  case 'agent_end': {
1734
- latestUserMessageIds.delete(sessionId);
1735
- latestAssistantMessageIds.delete(sessionId);
1736
1762
  const finalMessage = event.messages?.at?.(-1);
1737
1763
  if (finalMessage?.role === 'assistant' && finalMessage.stopReason === 'aborted') {
1738
1764
  publish('session.interrupted', { reason: 'user-abort', streaming: false }, sessionId, directory);
@@ -1741,15 +1767,23 @@ export function createSessionDaemon({
1741
1767
  }
1742
1768
  break;
1743
1769
  }
1744
- case 'auto_retry_start':
1745
- publish('session.lifecycle', {
1746
- state: 'retry',
1770
+ case 'auto_retry_start': {
1771
+ const retry = {
1747
1772
  attempt: event.attempt,
1748
1773
  next: Date.now() + event.delayMs,
1749
1774
  message: redactAttachmentPaths(event.errorMessage),
1750
- }, sessionId, directory);
1775
+ };
1776
+ retryStateBySession.set(sessionId, retry);
1777
+ publish('session.lifecycle', { state: 'retry', ...retry }, sessionId, directory);
1778
+ break;
1779
+ }
1780
+ case 'auto_retry_end':
1781
+ retryStateBySession.delete(sessionId);
1751
1782
  break;
1752
1783
  case 'agent_settled':
1784
+ retryStateBySession.delete(sessionId);
1785
+ latestUserMessageIds.delete(sessionId);
1786
+ latestAssistantMessageIds.delete(sessionId);
1753
1787
  publish('session.lifecycle', { state: 'idle' }, sessionId, directory);
1754
1788
  scheduleIdleDisposal(sessionId);
1755
1789
  break;
@@ -1924,35 +1958,16 @@ export function createSessionDaemon({
1924
1958
  }
1925
1959
  case 'sessions.navigate': {
1926
1960
  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
- }
1961
+ const requestedId = message.payload?.messageId;
1962
+ if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested tree entry is invalid.');
1963
+ const messageId = messageEntryAliases.resolve({
1964
+ cwd: activeRuntime.cwd,
1965
+ sessionId: message.payload.sessionId,
1966
+ requestedId,
1967
+ sessionManager: activeRuntime.session.sessionManager,
1968
+ });
1936
1969
  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;
1970
+ const result = await activeRuntime.session.navigateTree(messageId);
1956
1971
  if (result?.cancelled) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'Pi cancelled tree navigation.');
1957
1972
  const newLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
1958
1973
  const navigation = {
@@ -1967,8 +1982,16 @@ export function createSessionDaemon({
1967
1982
  case 'sessions.fork':
1968
1983
  case 'sessions.clone': {
1969
1984
  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.');
1985
+ const requestedId = message.command === 'sessions.fork' ? message.payload?.messageId : activeRuntime.session.sessionManager?.getLeafId?.();
1986
+ if (typeof requestedId !== 'string' || requestedId.length === 0) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'The Pi session has no fork point.');
1987
+ const entryId = message.command === 'sessions.fork'
1988
+ ? messageEntryAliases.resolve({
1989
+ cwd: activeRuntime.cwd,
1990
+ sessionId: message.payload.sessionId,
1991
+ requestedId,
1992
+ sessionManager: activeRuntime.session.sessionManager,
1993
+ })
1994
+ : requestedId;
1972
1995
  const result = await activeRuntime.fork(entryId, { position: 'at' });
1973
1996
  if (result.cancelled) throw new SessionDaemonProtocolError('SESSION_CREATE_CANCELLED', 'Pi cancelled session creation.');
1974
1997
  rememberRuntimeSession();
@@ -2136,6 +2159,7 @@ export function createSessionDaemon({
2136
2159
  if (platform !== 'win32') await chmod(endpoint, 0o600);
2137
2160
  started = true;
2138
2161
  } catch (error) {
2162
+ messageEntryAliases.clear();
2139
2163
  await disposeRuntime();
2140
2164
  server = undefined;
2141
2165
  throw error;
@@ -2149,8 +2173,12 @@ export function createSessionDaemon({
2149
2173
  clearTimeout(attempt.expiry);
2150
2174
  }
2151
2175
  loginAttempts.clear();
2176
+ retryStateBySession.clear();
2177
+ latestUserMessageIds.clear();
2178
+ latestAssistantMessageIds.clear();
2152
2179
  for (const client of clients) client.destroy();
2153
2180
  clients.clear();
2181
+ messageEntryAliases.clear();
2154
2182
  await new Promise((resolve, reject) => {
2155
2183
  server.close((error) => error ? reject(error) : resolve());
2156
2184
  });
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { StringDecoder } from 'node:string_decoder';
8
8
 
9
+ import { createMessageEntryAliases } from './message-entry-aliases.js';
9
10
  import { createSessionDaemon, isLocalSessionDaemonEndpoint } from './session-daemon.js';
10
11
 
11
12
  const credential = 'a-private-daemon-credential';
@@ -42,6 +43,7 @@ class FakeSession {
42
43
  getSessionFile: () => sessionFile,
43
44
  getHeader: () => ({ timestamp: '2026-01-01T00:00:00.000Z' }),
44
45
  getEntries: () => this.entries,
46
+ getEntry: (id) => this.entries.find((entry) => entry.id === id),
45
47
  getLeafId: () => 'fake-entry',
46
48
  getTree: () => [{ entry: { id: 'fake-entry', parentId: undefined, timestamp: '2026-01-01T00:00:00.000Z' }, children: [] }],
47
49
  appendSessionInfo: (name) => this.names.push(name),
@@ -315,6 +317,59 @@ describe('Pi session daemon spike', () => {
315
317
  await stale.close();
316
318
  });
317
319
 
320
+ it('keeps existing and late-joining device streams independent during one turn', async () => {
321
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-multi-client-'));
322
+ const endpoint = testDaemonEndpoint(root);
323
+ const session = new FakeSession();
324
+ session.isStreaming = true;
325
+ session.messages = [{
326
+ role: 'assistant',
327
+ content: [{ type: 'text', text: 'half' }],
328
+ provider: 'test',
329
+ model: 'model',
330
+ timestamp: 1_000,
331
+ }];
332
+ daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
333
+ await daemon.start();
334
+
335
+ const first = connectClient(endpoint);
336
+ await first.authenticate();
337
+ await first.request('sessions.create', { cwd: root });
338
+ const detail = await first.request('sessions.open', { sessionId: 'pi-session-1', cwd: root });
339
+ expect(detail.result).toMatchObject({
340
+ isStreaming: true,
341
+ lifecycle: 'busy',
342
+ lastSequence: expect.any(Number),
343
+ messages: [expect.objectContaining({ parts: [expect.objectContaining({ text: 'half' })] })],
344
+ });
345
+
346
+ const late = connectClient(endpoint);
347
+ await late.authenticate(credential, {
348
+ sessionId: 'pi-session-1',
349
+ fromSequence: detail.result.lastSequence,
350
+ });
351
+ const firstDelta = first.next((frame) => frame.event === 'assistant.message.delta');
352
+ const lateDelta = late.next((frame) => frame.event === 'assistant.message.delta');
353
+ session.emit({
354
+ type: 'message_update',
355
+ assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: ' plus the rest' },
356
+ });
357
+ const [firstDeltaFrame, lateDeltaFrame] = await Promise.all([firstDelta, lateDelta]);
358
+ expect(firstDeltaFrame.sequence).toBe(lateDeltaFrame.sequence);
359
+ expect(lateDeltaFrame.payload.delta).toBe(' plus the rest');
360
+
361
+ const firstIdle = first.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
362
+ const lateIdle = late.next((frame) => frame.event === 'session.lifecycle' && frame.payload?.state === 'idle');
363
+ session.isStreaming = false;
364
+ session.emit({ type: 'agent_settled' });
365
+ const [firstIdleFrame, lateIdleFrame] = await Promise.all([firstIdle, lateIdle]);
366
+ expect(firstIdleFrame.sequence).toBe(lateIdleFrame.sequence);
367
+ expect(firstIdleFrame.sequence).toBeGreaterThan(firstDeltaFrame.sequence);
368
+
369
+ await late.close();
370
+ await first.close();
371
+ });
372
+
318
373
  it('lists only validated cwd-scoped sessions without exposing Pi JSONL paths', async () => {
319
374
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
320
375
  const endpoint = testDaemonEndpoint(root);
@@ -611,6 +666,174 @@ describe('Pi session daemon spike', () => {
611
666
  await client.close();
612
667
  });
613
668
 
669
+ it('resolves live message ids to Pi entry ids for navigation and forking', async () => {
670
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-'));
671
+ const endpoint = testDaemonEndpoint(root);
672
+ const sessionFile = join(root, 'fixture.jsonl');
673
+ await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
674
+ const session = new FakeSession('fixture-session', sessionFile);
675
+ const hydratedMessage = { role: 'user', timestamp: 1, content: 'm3' };
676
+ session.entries.push({ type: 'message', id: 'm3-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: hydratedMessage });
677
+ const navigated = [];
678
+ session.navigateTree = async (entryId) => {
679
+ if (!session.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
680
+ navigated.push(entryId);
681
+ return { cancelled: false };
682
+ };
683
+ const forked = [];
684
+ const runtime = new FakeRuntime({ cwd: root, session });
685
+ runtime.fork = async (entryId) => {
686
+ if (!session.sessionManager.getEntry(entryId)) throw new Error('Invalid entry ID for forking');
687
+ forked.push(entryId);
688
+ return { cancelled: false };
689
+ };
690
+ daemon = createSessionDaemon({
691
+ endpoint,
692
+ credential,
693
+ cwd: root,
694
+ createRuntime: async () => runtime,
695
+ listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
696
+ });
697
+ await daemon.start();
698
+
699
+ const client = connectClient(endpoint);
700
+ await client.authenticate();
701
+ await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
702
+
703
+ await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: 'm3-entry' })).resolves.toMatchObject({
704
+ result: { navigation: { targetEntryId: 'm3-entry' } },
705
+ });
706
+
707
+ const replacementUser = { role: 'user', timestamp: 2, content: 'm4 replacement' };
708
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user' && frame.payload?.text === 'm4 replacement');
709
+ session.emit({ type: 'message_start', message: replacementUser });
710
+ session.emit({ type: 'message_end', message: replacementUser });
711
+ session.entries.push({ type: 'message', id: 'm4-rev-entry', parentId: 'm3-entry', timestamp: '2026-01-01T00:00:02.000Z', message: replacementUser });
712
+ const publishedUserId = (await userStartPromise).payload.messageId;
713
+ await Promise.resolve();
714
+
715
+ await expect(client.request('sessions.navigate', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({
716
+ result: { navigation: { targetEntryId: 'm4-rev-entry' } },
717
+ });
718
+ await expect(client.request('sessions.fork', { sessionId: 'fixture-session', directory: root, messageId: publishedUserId })).resolves.toMatchObject({ result: expect.any(Object) });
719
+
720
+ // Pi agent-core shallow-copies the streaming start; message_end carries
721
+ // the distinct finalized object that SessionManager persists.
722
+ const assistantStartMessage = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [] };
723
+ const replacementAssistant = { role: 'assistant', timestamp: 3, provider: 'test', model: 'model', content: [{ type: 'text', text: 'replacement answer' }] };
724
+ const assistantStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
725
+ session.emit({ type: 'message_start', message: assistantStartMessage });
726
+ session.emit({ type: 'message_end', message: replacementAssistant });
727
+ session.entries.push({ type: 'message', id: 'm4-assistant-entry', parentId: 'm4-rev-entry', timestamp: '2026-01-01T00:00:03.000Z', message: replacementAssistant });
728
+ const publishedAssistantId = (await assistantStartPromise).payload.messageId;
729
+ await Promise.resolve();
730
+
731
+ await expect(client.request('sessions.navigate', {
732
+ sessionId: 'fixture-session',
733
+ directory: root,
734
+ messageId: `${publishedAssistantId}:text:0`,
735
+ })).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'm4-assistant-entry' } } });
736
+ await expect(client.request('sessions.fork', {
737
+ sessionId: 'fixture-session',
738
+ directory: root,
739
+ messageId: `${publishedAssistantId}:text:0`,
740
+ })).resolves.toMatchObject({ result: expect.any(Object) });
741
+
742
+ expect(navigated).toEqual(['m3-entry', 'm4-rev-entry', 'm4-assistant-entry']);
743
+ expect(forked).toEqual(['m4-rev-entry', 'm4-assistant-entry']);
744
+ await expect(client.request('sessions.fork', {
745
+ sessionId: 'fixture-session',
746
+ directory: root,
747
+ messageId: 'user-fixture-session-unknown',
748
+ })).rejects.toThrow('Daemon connection closed');
749
+
750
+ const navigationClient = connectClient(endpoint);
751
+ await navigationClient.authenticate();
752
+ await expect(navigationClient.request('sessions.navigate', {
753
+ sessionId: 'fixture-session',
754
+ directory: root,
755
+ messageId: 'user-fixture-session-unknown',
756
+ })).rejects.toThrow('Daemon connection closed');
757
+ });
758
+
759
+ it('keeps live message aliases across idle disposal and runtime reopening', async () => {
760
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-message-alias-idle-'));
761
+ const endpoint = testDaemonEndpoint(root);
762
+ const sessionFile = join(root, 'fixture.jsonl');
763
+ await writeFile(sessionFile, `${JSON.stringify({ type: 'session', id: 'fixture-session', cwd: root, timestamp: '2026-01-01T00:00:00.000Z' })}\n`);
764
+ const liveMessage = { role: 'user', timestamp: 1, content: 'persist me' };
765
+ const persistedEntry = { type: 'message', id: 'persisted-entry', parentId: null, timestamp: '2026-01-01T00:00:01.000Z', message: liveMessage };
766
+ const firstSession = new FakeSession('fixture-session', sessionFile);
767
+ const firstRuntime = new FakeRuntime({ cwd: root, session: firstSession });
768
+ const reopenedSession = new FakeSession('fixture-session', sessionFile);
769
+ reopenedSession.entries.push({ ...persistedEntry, message: { ...liveMessage } });
770
+ reopenedSession.navigateTree = async (entryId) => {
771
+ if (!reopenedSession.sessionManager.getEntry(entryId)) throw new Error(`Entry ${entryId} not found`);
772
+ reopenedSession.navigatedTo = entryId;
773
+ return { cancelled: false };
774
+ };
775
+ const reopenedRuntime = new FakeRuntime({ cwd: root, session: reopenedSession });
776
+ let runtimeCount = 0;
777
+ daemon = createSessionDaemon({
778
+ endpoint,
779
+ credential,
780
+ cwd: root,
781
+ idleTimeoutMs: 10,
782
+ listSessions: async () => [{ path: sessionFile, id: 'fixture-session', cwd: root }],
783
+ createRuntime: async () => (++runtimeCount === 1 ? firstRuntime : reopenedRuntime),
784
+ });
785
+ await daemon.start();
786
+
787
+ const client = connectClient(endpoint);
788
+ await client.authenticate();
789
+ await client.request('sessions.open', { sessionId: 'fixture-session', directory: root });
790
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
791
+ firstSession.emit({ type: 'message_start', message: liveMessage });
792
+ firstSession.emit({ type: 'message_end', message: liveMessage });
793
+ firstSession.entries.push(persistedEntry);
794
+ const publishedId = (await userStartPromise).payload.messageId;
795
+ await Promise.resolve();
796
+ firstSession.emit({ type: 'agent_settled' });
797
+ await new Promise((resolve) => setTimeout(resolve, 30));
798
+ expect(firstRuntime.disposed).toBe(true);
799
+
800
+ await expect(client.request('sessions.navigate', {
801
+ sessionId: 'fixture-session',
802
+ directory: root,
803
+ messageId: publishedId,
804
+ })).resolves.toMatchObject({ result: { navigation: { targetEntryId: 'persisted-entry' } } });
805
+ expect(reopenedSession.navigatedTo).toBe('persisted-entry');
806
+ await client.close();
807
+ });
808
+
809
+ it('scopes live message aliases by directory and session id', () => {
810
+ const aliases = createMessageEntryAliases({ scheduleMicrotask: (callback) => callback() });
811
+ const messageA = { role: 'user', content: 'same' };
812
+ const messageB = { role: 'user', content: 'same' };
813
+ const managerA = {
814
+ getEntry: () => undefined,
815
+ getEntries: () => [{ type: 'message', id: 'entry-a', message: messageA }],
816
+ };
817
+ const managerB = {
818
+ getEntry: () => undefined,
819
+ getEntries: () => [{ type: 'message', id: 'entry-b', message: messageB }],
820
+ };
821
+ aliases.retain({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA });
822
+ aliases.retain({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB });
823
+ aliases.observeMessageEnd({ cwd: '/project-a', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageA, sessionManager: managerA });
824
+ aliases.observeMessageEnd({ cwd: '/project-b', sessionId: 'same-session', syntheticMessageId: 'user-same-session-1', message: messageB, sessionManager: managerB });
825
+
826
+ expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('entry-a');
827
+ expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('entry-b');
828
+ expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-unknown', sessionManager: managerA })).toBe('user-same-session-unknown');
829
+
830
+ aliases.clearSession({ cwd: '/project-a', sessionId: 'same-session' });
831
+ expect(aliases.resolve({ cwd: '/project-a', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerA })).toBe('user-same-session-1');
832
+ expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1', sessionManager: managerB })).toBe('entry-b');
833
+ aliases.clear();
834
+ expect(aliases.resolve({ cwd: '/project-b', sessionId: 'same-session', requestedId: 'user-same-session-1:text:0', sessionManager: managerB })).toBe('user-same-session-1');
835
+ });
836
+
614
837
  it('disposes an idle runtime without deleting its Pi JSONL and restores it on demand', async () => {
615
838
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
616
839
  const endpoint = testDaemonEndpoint(root);
@@ -971,7 +1194,7 @@ describe('Pi session daemon spike', () => {
971
1194
  await client.close();
972
1195
  });
973
1196
 
974
- it('keeps retryable provider failures in retry lifecycle until Pi exhausts retry', async () => {
1197
+ it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
975
1198
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
976
1199
  const endpoint = testDaemonEndpoint(root);
977
1200
  const session = new FakeSession('pi-session-retry');
@@ -981,6 +1204,15 @@ describe('Pi session daemon spike', () => {
981
1204
  await client.authenticate();
982
1205
  await client.request('sessions.create', { cwd: root });
983
1206
 
1207
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
1208
+ session.emit({ type: 'message_start', message: { role: 'user', content: 'recover this turn', timestamp: 1_000 } });
1209
+ const userStart = await userStartPromise;
1210
+
1211
+ const failedStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
1212
+ session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_100 } });
1213
+ const failedStart = await failedStartPromise;
1214
+ session.emit({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Rate limit exceeded' } });
1215
+
984
1216
  const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
985
1217
  const prematureError = client.next((frame) => frame.event === 'session.error');
986
1218
  session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
@@ -989,11 +1221,35 @@ describe('Pi session daemon spike', () => {
989
1221
  await expect(retryLifecycle).resolves.toMatchObject({
990
1222
  payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
991
1223
  });
1224
+
1225
+ const retryObserver = connectClient(endpoint);
1226
+ const retrySnapshot = await retryObserver.authenticate();
1227
+ expect(retrySnapshot.payload).toMatchObject({
1228
+ lifecycle: 'retry',
1229
+ retry: { attempt: 1, message: 'Rate limit exceeded' },
1230
+ });
1231
+
1232
+ session.emit({ type: 'agent_start' });
1233
+ const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
1234
+ && frame.payload?.role === 'assistant'
1235
+ && frame.payload.messageId !== failedStart.payload.messageId);
1236
+ session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_200 } });
1237
+ const recoveredStart = await recoveredStartPromise;
1238
+ const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
1239
+ && frame.payload?.messageId === recoveredStart.payload.messageId);
1240
+ session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered' } });
1241
+
1242
+ await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered' } });
1243
+ expect(failedStart.payload.parentId).toBe(userStart.payload.messageId);
1244
+ expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
992
1245
  await expect(prematureError).rejects.toThrow(/Timed out/);
1246
+
1247
+ session.emit({ type: 'agent_settled' });
1248
+ await retryObserver.close();
993
1249
  await client.close();
994
1250
  });
995
1251
 
996
- it('preserves assistant messageId for tool executions occurring after message_end', async () => {
1252
+ it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
997
1253
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
998
1254
  const endpoint = testDaemonEndpoint(root);
999
1255
  const session = new FakeSession('pi-session-tool-seq');
@@ -1003,16 +1259,28 @@ describe('Pi session daemon spike', () => {
1003
1259
  await client.authenticate();
1004
1260
  await client.request('sessions.create', { cwd: root });
1005
1261
 
1006
- const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start');
1262
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
1263
+ const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
1007
1264
  const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
1008
1265
  const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
1009
1266
  const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
1010
1267
 
1268
+ session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
1011
1269
  session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 1 } });
1012
- session.emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Calling tool' }] } });
1270
+ session.emit({
1271
+ type: 'message_end',
1272
+ message: {
1273
+ role: 'assistant',
1274
+ content: [
1275
+ { type: 'text', text: 'Calling tool' },
1276
+ { type: 'toolCall', id: 'tool-call-1', name: 'bash', arguments: { command: 'echo hi' } },
1277
+ ],
1278
+ },
1279
+ });
1013
1280
  session.emit({ type: 'tool_execution_start', toolCallId: 'tool-call-1', toolName: 'bash', args: { command: 'echo hi' } });
1014
- session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'hi' }] }, isError: false });
1281
+ session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'command failed' }] }, isError: true });
1015
1282
 
1283
+ const userStart = await userStartPromise;
1016
1284
  const messageStart = await messageStartPromise;
1017
1285
  const messageEnd = await messageEndPromise;
1018
1286
  const toolStart = await toolStartPromise;
@@ -1020,11 +1288,26 @@ describe('Pi session daemon spike', () => {
1020
1288
 
1021
1289
  expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
1022
1290
  expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
1291
+ expect(messageEnd.payload.continuing).toBe(true);
1023
1292
  expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
1024
1293
  expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1025
1294
  expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
1026
1295
  expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1027
-
1296
+ expect(toolEnd.payload).toMatchObject({ state: 'error', isError: true, error: 'command failed' });
1297
+
1298
+ const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
1299
+ && frame.payload?.role === 'assistant'
1300
+ && frame.payload.messageId !== messageStart.payload.messageId);
1301
+ session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 2 } });
1302
+ const recoveredStart = await recoveredStartPromise;
1303
+ const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
1304
+ && frame.payload?.messageId === recoveredStart.payload.messageId);
1305
+ session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered from tool failure' } });
1306
+
1307
+ await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered from tool failure' } });
1308
+ expect(messageStart.payload.parentId).toBe(userStart.payload.messageId);
1309
+ expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
1310
+ session.emit({ type: 'agent_settled' });
1028
1311
  await client.close();
1029
1312
  });
1030
1313
 
@@ -24,6 +24,20 @@ class PiSessionDaemonUnavailableError extends Error {
24
24
 
25
25
  const delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
26
26
 
27
+ const rejectAfter = (promise, timeoutMs, code) => new Promise((resolvePromise, rejectPromise) => {
28
+ const timer = setTimeout(() => rejectPromise(new SessionDaemonClientError(code)), timeoutMs);
29
+ promise.then(
30
+ (value) => {
31
+ clearTimeout(timer);
32
+ resolvePromise(value);
33
+ },
34
+ (error) => {
35
+ clearTimeout(timer);
36
+ rejectPromise(error);
37
+ },
38
+ );
39
+ });
40
+
27
41
  const getWindowsOwnerKey = () => {
28
42
  try {
29
43
  return createHash('sha256').update(userInfo().username).digest('hex').slice(0, 16);
@@ -253,7 +267,12 @@ export const createPiSessionDaemonSupervisor = ({
253
267
  throw new PiSessionDaemonUnavailableError('DAEMON_UNAVAILABLE');
254
268
  }
255
269
  try {
256
- const health = await request({ endpoint: paths.endpoint, credential, command: 'runtime.health' });
270
+ const health = await rejectAfter(request({
271
+ endpoint: paths.endpoint,
272
+ credential,
273
+ command: 'runtime.health',
274
+ timeoutMs: startupTimeoutMs,
275
+ }), startupTimeoutMs, 'DAEMON_UNAVAILABLE');
257
276
  if (health?.state !== 'ready' || health.daemonPid !== state.pid) {
258
277
  throw new PiSessionDaemonUnavailableError('DAEMON_IDENTITY_MISMATCH');
259
278
  }