@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
@@ -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);
@@ -793,6 +1016,81 @@ describe('Pi session daemon spike', () => {
793
1016
  await client.close();
794
1017
  });
795
1018
 
1019
+ it('projects large tool payloads without blocking attachment redaction', async () => {
1020
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-large-payload-'));
1021
+ const endpoint = testDaemonEndpoint(root);
1022
+ const persistedSessionFile = join(root, 'persisted.jsonl');
1023
+ await writeFile(persistedSessionFile, `{"type":"session","id":"pi-session-large","cwd":"${root}"}\n`);
1024
+ const session = new FakeSession('pi-session-large', persistedSessionFile);
1025
+ const largeEncodedValue = 'A'.repeat(80_000);
1026
+ session.entries = [{
1027
+ type: 'message',
1028
+ id: 'assistant-large-tool',
1029
+ timestamp: '2026-01-01T00:00:02.000Z',
1030
+ message: {
1031
+ role: 'assistant',
1032
+ provider: 'test',
1033
+ model: 'model',
1034
+ content: [{ type: 'toolCall', id: 'large-tool', name: 'read', arguments: { encoded: largeEncodedValue } }],
1035
+ },
1036
+ }, {
1037
+ type: 'message',
1038
+ id: 'large-tool-result',
1039
+ timestamp: '2026-01-01T00:00:03.000Z',
1040
+ message: {
1041
+ role: 'toolResult',
1042
+ toolCallId: 'large-tool',
1043
+ toolName: 'read',
1044
+ content: [{ type: 'text', text: largeEncodedValue }],
1045
+ details: {
1046
+ encoded: largeEncodedValue,
1047
+ windowsPath: 'C:\\Temp\\pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png',
1048
+ bracketed: '[Attachment report.png is available at /tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png]',
1049
+ punctuated: 'before,/tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png;after',
1050
+ unicodePrefix: 'İ /tmp/pi-clipboard-7f7ec702-256a-4783-855c-df34e3ecedab.png',
1051
+ },
1052
+ isError: false,
1053
+ },
1054
+ }];
1055
+ const runtime = new FakeRuntime({ cwd: root, session });
1056
+ daemon = createSessionDaemon({
1057
+ endpoint,
1058
+ credential,
1059
+ cwd: root,
1060
+ createRuntime: async () => runtime,
1061
+ listSessions: async () => [{
1062
+ path: persistedSessionFile,
1063
+ id: 'pi-session-large',
1064
+ cwd: root,
1065
+ created: new Date('2026-01-01T00:00:00.000Z'),
1066
+ modified: new Date('2026-01-01T00:00:01.000Z'),
1067
+ messageCount: 2,
1068
+ }],
1069
+ });
1070
+ await daemon.start();
1071
+ const client = connectClient(endpoint);
1072
+ await client.authenticate();
1073
+
1074
+ const startedAt = performance.now();
1075
+ const opened = await client.request('sessions.open', { sessionId: 'pi-session-large', directory: root });
1076
+ const elapsedMs = performance.now() - startedAt;
1077
+
1078
+ expect(elapsedMs).toBeLessThan(900);
1079
+ expect(opened.result.messages[0].parts[0]).toMatchObject({
1080
+ input: { encoded: largeEncodedValue },
1081
+ output: largeEncodedValue,
1082
+ metadata: {
1083
+ encoded: largeEncodedValue,
1084
+ windowsPath: '[attachment]',
1085
+ bracketed: '[attachment]',
1086
+ punctuated: 'before,[attachment];after',
1087
+ unicodePrefix: 'İ [attachment]',
1088
+ },
1089
+ });
1090
+ expect(JSON.stringify(opened.result)).not.toContain('pi-clipboard-');
1091
+ await client.close();
1092
+ }, 2_000);
1093
+
796
1094
  it('keeps Pi global/project defaults and trust decisions authoritative', async () => {
797
1095
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
798
1096
  const cwd = join(root, 'project');
@@ -971,7 +1269,7 @@ describe('Pi session daemon spike', () => {
971
1269
  await client.close();
972
1270
  });
973
1271
 
974
- it('keeps retryable provider failures in retry lifecycle until Pi exhausts retry', async () => {
1272
+ it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
975
1273
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
976
1274
  const endpoint = testDaemonEndpoint(root);
977
1275
  const session = new FakeSession('pi-session-retry');
@@ -981,6 +1279,15 @@ describe('Pi session daemon spike', () => {
981
1279
  await client.authenticate();
982
1280
  await client.request('sessions.create', { cwd: root });
983
1281
 
1282
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
1283
+ session.emit({ type: 'message_start', message: { role: 'user', content: 'recover this turn', timestamp: 1_000 } });
1284
+ const userStart = await userStartPromise;
1285
+
1286
+ const failedStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
1287
+ session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_100 } });
1288
+ const failedStart = await failedStartPromise;
1289
+ session.emit({ type: 'message_end', message: { role: 'assistant', content: [], stopReason: 'error', errorMessage: 'Rate limit exceeded' } });
1290
+
984
1291
  const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
985
1292
  const prematureError = client.next((frame) => frame.event === 'session.error');
986
1293
  session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
@@ -989,11 +1296,35 @@ describe('Pi session daemon spike', () => {
989
1296
  await expect(retryLifecycle).resolves.toMatchObject({
990
1297
  payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
991
1298
  });
1299
+
1300
+ const retryObserver = connectClient(endpoint);
1301
+ const retrySnapshot = await retryObserver.authenticate();
1302
+ expect(retrySnapshot.payload).toMatchObject({
1303
+ lifecycle: 'retry',
1304
+ retry: { attempt: 1, message: 'Rate limit exceeded' },
1305
+ });
1306
+
1307
+ session.emit({ type: 'agent_start' });
1308
+ const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
1309
+ && frame.payload?.role === 'assistant'
1310
+ && frame.payload.messageId !== failedStart.payload.messageId);
1311
+ session.emit({ type: 'message_start', message: { role: 'assistant', content: [], timestamp: 1_200 } });
1312
+ const recoveredStart = await recoveredStartPromise;
1313
+ const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
1314
+ && frame.payload?.messageId === recoveredStart.payload.messageId);
1315
+ session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered' } });
1316
+
1317
+ await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered' } });
1318
+ expect(failedStart.payload.parentId).toBe(userStart.payload.messageId);
1319
+ expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
992
1320
  await expect(prematureError).rejects.toThrow(/Timed out/);
1321
+
1322
+ session.emit({ type: 'agent_settled' });
1323
+ await retryObserver.close();
993
1324
  await client.close();
994
1325
  });
995
1326
 
996
- it('preserves assistant messageId for tool executions occurring after message_end', async () => {
1327
+ it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
997
1328
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
998
1329
  const endpoint = testDaemonEndpoint(root);
999
1330
  const session = new FakeSession('pi-session-tool-seq');
@@ -1003,16 +1334,28 @@ describe('Pi session daemon spike', () => {
1003
1334
  await client.authenticate();
1004
1335
  await client.request('sessions.create', { cwd: root });
1005
1336
 
1006
- const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start');
1337
+ const userStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'user');
1338
+ const messageStartPromise = client.next((frame) => frame.event === 'assistant.message.start' && frame.payload?.role === 'assistant');
1007
1339
  const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
1008
1340
  const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
1009
1341
  const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
1010
1342
 
1343
+ session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
1011
1344
  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' }] } });
1345
+ session.emit({
1346
+ type: 'message_end',
1347
+ message: {
1348
+ role: 'assistant',
1349
+ content: [
1350
+ { type: 'text', text: 'Calling tool' },
1351
+ { type: 'toolCall', id: 'tool-call-1', name: 'bash', arguments: { command: 'echo hi' } },
1352
+ ],
1353
+ },
1354
+ });
1013
1355
  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 });
1356
+ session.emit({ type: 'tool_execution_end', toolCallId: 'tool-call-1', toolName: 'bash', result: { content: [{ type: 'text', text: 'command failed' }] }, isError: true });
1015
1357
 
1358
+ const userStart = await userStartPromise;
1016
1359
  const messageStart = await messageStartPromise;
1017
1360
  const messageEnd = await messageEndPromise;
1018
1361
  const toolStart = await toolStartPromise;
@@ -1020,11 +1363,26 @@ describe('Pi session daemon spike', () => {
1020
1363
 
1021
1364
  expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
1022
1365
  expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
1366
+ expect(messageEnd.payload.continuing).toBe(true);
1023
1367
  expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
1024
1368
  expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1025
1369
  expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
1026
1370
  expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1027
-
1371
+ expect(toolEnd.payload).toMatchObject({ state: 'error', isError: true, error: 'command failed' });
1372
+
1373
+ const recoveredStartPromise = client.next((frame) => frame.event === 'assistant.message.start'
1374
+ && frame.payload?.role === 'assistant'
1375
+ && frame.payload.messageId !== messageStart.payload.messageId);
1376
+ session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 2 } });
1377
+ const recoveredStart = await recoveredStartPromise;
1378
+ const recoveredDeltaPromise = client.next((frame) => frame.event === 'assistant.message.delta'
1379
+ && frame.payload?.messageId === recoveredStart.payload.messageId);
1380
+ session.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Recovered from tool failure' } });
1381
+
1382
+ await expect(recoveredDeltaPromise).resolves.toMatchObject({ payload: { delta: 'Recovered from tool failure' } });
1383
+ expect(messageStart.payload.parentId).toBe(userStart.payload.messageId);
1384
+ expect(recoveredStart.payload.parentId).toBe(userStart.payload.messageId);
1385
+ session.emit({ type: 'agent_settled' });
1028
1386
  await client.close();
1029
1387
  });
1030
1388
 
@@ -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
  }
@@ -194,6 +194,49 @@ describe('Pi session daemon supervisor', () => {
194
194
  expect(windowsSupervisor.paths.endpoint).toMatch(/^\\\\\.\\pipe\\pichamber-pi-session-daemon-[0-9a-f]{16}$/);
195
195
  });
196
196
 
197
+ it('bounds an unresponsive Windows daemon probe by the supervisor operation timeout', async () => {
198
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-hung-win-'));
199
+ const dataDir = join(root, 'data');
200
+ const piDataDir = join(dataDir, 'pi');
201
+ await mkdir(piDataDir, { recursive: true });
202
+ const daemonPid = 7_331;
203
+ const processLike = {
204
+ pid: 4_242,
205
+ execPath: process.execPath,
206
+ versions: { electron: '41.2.1' },
207
+ kill(pid, signal) {
208
+ if (pid === daemonPid && signal === 0) return;
209
+ const error = new Error('ESRCH');
210
+ error.code = 'ESRCH';
211
+ throw error;
212
+ },
213
+ };
214
+ const probeTimeoutMs = 20;
215
+ const windowsSupervisor = createPiSessionDaemonSupervisor({
216
+ env: { PICHAMBER_DATA_DIR: dataDir },
217
+ cwd: root,
218
+ dataDir,
219
+ platform: 'win32',
220
+ processLike,
221
+ startupTimeoutMs: probeTimeoutMs,
222
+ request: () => new Promise(() => {}),
223
+ });
224
+ await writeFile(windowsSupervisor.paths.credentialFile, `${'a'.repeat(64)}\n`);
225
+ await writeFile(windowsSupervisor.paths.stateFile, JSON.stringify({
226
+ protocolVersion: 1,
227
+ pid: daemonPid,
228
+ endpoint: windowsSupervisor.paths.endpoint,
229
+ startedAt: new Date().toISOString(),
230
+ }));
231
+
232
+ const startedAt = Date.now();
233
+ await expect(windowsSupervisor.health()).resolves.toMatchObject({
234
+ state: 'unavailable',
235
+ error: { code: 'DAEMON_UNAVAILABLE' },
236
+ });
237
+ expect(Date.now() - startedAt).toBeLessThan(probeTimeoutMs * 5);
238
+ });
239
+
197
240
  it('recovers from a leftover empty daemon lock instead of failing as unavailable', async () => {
198
241
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-empty-lock-'));
199
242
  const cwd = join(root, 'project');
@@ -63,6 +63,16 @@ const mergeThinkingByModel = (current, incoming) => {
63
63
  return next;
64
64
  };
65
65
 
66
+ const DEFAULT_RETRY_LIMIT = 3;
67
+ const MIN_RETRY_LIMIT = 0;
68
+ const MAX_RETRY_LIMIT = 10;
69
+
70
+ const normalizeRetryLimit = (value) => {
71
+ if (value === undefined || value === null) return undefined;
72
+ if (!Number.isInteger(value) || value < MIN_RETRY_LIMIT || value > MAX_RETRY_LIMIT) throw invalidSettings();
73
+ return value;
74
+ };
75
+
66
76
  const normalize = (value) => {
67
77
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalidSettings();
68
78
  const model = normalizeModel(value.defaultModel);
@@ -77,6 +87,7 @@ const normalize = (value) => {
77
87
  if (!thinkingByModel[key]) thinkingByModel[key] = fallbackThinking;
78
88
  fallbackThinking = undefined;
79
89
  }
90
+ const retryLimit = normalizeRetryLimit(value.defaultRetryLimit);
80
91
  return {
81
92
  version: 1,
82
93
  ...(model ? { defaultModel: model } : {}),
@@ -84,6 +95,7 @@ const normalize = (value) => {
84
95
  ...(walkthroughModel ? { walkthroughModel } : {}),
85
96
  ...(Object.keys(thinkingByModel).length > 0 ? { defaultThinkingByModel: thinkingByModel } : {}),
86
97
  ...(fallbackThinking ? { defaultThinking: fallbackThinking } : {}),
98
+ ...(retryLimit !== undefined ? { defaultRetryLimit: retryLimit } : {}),
87
99
  };
88
100
  };
89
101
 
@@ -92,6 +104,10 @@ const normalize = (value) => {
92
104
  * Pi's settings.json: Pi remains authoritative when no PiChamber override is
93
105
  * configured, and credentials never enter this sidecar.
94
106
  */
107
+ export const PI_DEFAULT_RETRY_LIMIT = DEFAULT_RETRY_LIMIT;
108
+ export const PI_MIN_RETRY_LIMIT = MIN_RETRY_LIMIT;
109
+ export const PI_MAX_RETRY_LIMIT = MAX_RETRY_LIMIT;
110
+
95
111
  export const createPiSettingsStore = ({ file = join(resolvePiChamberDataDir(), 'pi', 'settings.json') } = {}) => {
96
112
  let writeChain = Promise.resolve();
97
113
 
@@ -115,6 +131,7 @@ export const createPiSettingsStore = ({ file = join(resolvePiChamberDataDir(), '
115
131
  ...(Object.hasOwn(patch, 'defaultThinking') ? { defaultThinking: patch.defaultThinking } : {}),
116
132
  ...(Object.hasOwn(patch, 'smallModel') ? { smallModel: patch.smallModel } : {}),
117
133
  ...(Object.hasOwn(patch, 'walkthroughModel') ? { walkthroughModel: patch.walkthroughModel } : {}),
134
+ ...(Object.hasOwn(patch, 'defaultRetryLimit') ? { defaultRetryLimit: patch.defaultRetryLimit } : {}),
118
135
  defaultThinkingByModel: Object.hasOwn(patch, 'defaultThinkingByModel')
119
136
  ? mergeThinkingByModel(current.defaultThinkingByModel ?? {}, patch.defaultThinkingByModel)
120
137
  : (current.defaultThinkingByModel ?? {}),
@@ -110,4 +110,37 @@ describe('Pi settings sidecar', () => {
110
110
  await store.update({ defaultThinkingByModel: { 'provider/a': 'low' } });
111
111
  await expect(store.update({ defaultThinkingByModel: null })).resolves.toEqual({ version: 1 });
112
112
  });
113
+
114
+ it('round-trips default retry limit with validation and persistence', async () => {
115
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
116
+ const file = join(root, 'pi', 'settings.json');
117
+ const store = createPiSettingsStore({ file });
118
+ await expect(store.update({ defaultRetryLimit: 5 })).resolves.toEqual({ version: 1, defaultRetryLimit: 5 });
119
+ await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 5 });
120
+ await expect(store.update({ defaultRetryLimit: 0 })).resolves.toEqual({ version: 1, defaultRetryLimit: 0 });
121
+ await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 0 });
122
+ });
123
+
124
+ it('rejects invalid retry limits without erasing existing value', async () => {
125
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
126
+ const file = join(root, 'pi', 'settings.json');
127
+ const store = createPiSettingsStore({ file });
128
+ await store.update({ defaultRetryLimit: 3 });
129
+ await expect(store.update({ defaultRetryLimit: -1 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
130
+ await expect(store.update({ defaultRetryLimit: 11 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
131
+ await expect(store.update({ defaultRetryLimit: 2.5 })).rejects.toMatchObject({ code: 'PI_SETTINGS_INVALID' });
132
+ await expect(store.read()).resolves.toEqual({ version: 1, defaultRetryLimit: 3 });
133
+ });
134
+
135
+ it('preserves explicit per-run retry over stored default', async () => {
136
+ const resolveEffective = (explicit, stored) => {
137
+ if (Number.isInteger(explicit)) return explicit;
138
+ if (Number.isInteger(stored)) return stored;
139
+ return 3;
140
+ };
141
+ expect(resolveEffective(undefined, 5)).toBe(5);
142
+ expect(resolveEffective(7, 5)).toBe(7);
143
+ expect(resolveEffective(undefined, undefined)).toBe(3);
144
+ expect(resolveEffective(0, 5)).toBe(0);
145
+ });
113
146
  });