@pi-chamber/web 0.2.2 → 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 (71) hide show
  1. package/bin/cli.test.js +6 -8
  2. package/bin/lib/cli-tunnel-capabilities.js +0 -2
  3. package/dist/assets/{CommitSection-DPjTv8fA.js → CommitSection-C336377Z.js} +1 -1
  4. package/dist/assets/{DiagramEditor-ChOor9l9.js → DiagramEditor-gHLCXHtr.js} +1 -1
  5. package/dist/assets/{DiagramView-3hAT86v_.js → DiagramView-DlHzRRBc.js} +1 -1
  6. package/dist/assets/{DiffView-D1dyOiw2.js → DiffView-5wHwK-lg.js} +1 -1
  7. package/dist/assets/{FilesView-BCzghrc7.js → FilesView-5bswI3JZ.js} +6 -6
  8. package/dist/assets/{GitView-CYF1E1p4.js → GitView-BfykGpGh.js} +4 -4
  9. package/dist/assets/{JsonTreeView-CrJO6LNu.js → JsonTreeView-HVwQS7qG.js} +1 -1
  10. package/dist/assets/{MarkdownRendererImpl-BmKjrkGD.js → MarkdownRendererImpl-kjd4e0N8.js} +1 -1
  11. package/dist/assets/{PatchDiff-DgvPLx6x.js → PatchDiff-Dlm4RU8n.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-InnT0JVA.js → PierreDiffViewer-St32rXlD.js} +1 -1
  13. package/dist/assets/{SessionSwitcherDropdown-C0OdvirK.js → SessionSwitcherDropdown-D5bWfZNA.js} +100 -101
  14. package/dist/assets/SettingsView-FGe5TEQq.js +4 -0
  15. package/dist/assets/SettingsWindow-Ck_uhTnx.js +1 -0
  16. package/dist/assets/{ToolOutputDialog-gzwcb8nI.js → ToolOutputDialog-BCONUv3r.js} +5 -5
  17. package/dist/assets/{ToolPartDiffPreview-C45V5bhy.js → ToolPartDiffPreview-BdVuk2kc.js} +1 -1
  18. package/dist/assets/{VirtualizedFileDiff-uKfnkQWb.js → VirtualizedFileDiff-BsAZM0jo.js} +1 -1
  19. package/dist/assets/{collapsible-Bq8sDiKS.js → collapsible-Wnmq-13H.js} +1 -1
  20. package/dist/assets/{computeVirtualFileMetrics-BPgZ_RSe.js → computeVirtualFileMetrics-skbyL2Az.js} +2 -2
  21. package/dist/assets/{contextFileOpenGuard-CydO7PkI.js → contextFileOpenGuard-BsxQITMZ.js} +1 -1
  22. package/dist/assets/index-8TL2HlaR.js +8 -0
  23. package/dist/assets/{index-BSuNNtBh.js → index-Bfp6eYap.js} +1 -1
  24. package/dist/assets/index-riABQmQ_.css +1 -0
  25. package/dist/assets/{isDiffPlainText-f9b4cKNo.js → isDiffPlainText-dZkfk3yG.js} +1 -1
  26. package/dist/assets/{isFilePlainText-BjESb-gU.js → isFilePlainText-UL_tMQZx.js} +1 -1
  27. package/dist/assets/{main-B508FBXr.js → main-CHQfoyRP.js} +2 -2
  28. package/dist/assets/main-DbnYXZeK.js +133 -0
  29. package/dist/assets/{markdownCore-BFmf8a-O.js → markdownCore-qX3mJFkL.js} +2 -2
  30. package/dist/assets/{miniChat-BTlz8IPL.js → miniChat-3QtCNwHl.js} +2 -2
  31. package/dist/assets/{mobile-DBkrRt80.js → mobile-B_CyfRAX.js} +2 -2
  32. package/dist/assets/renderElectronMiniChatApp-OToslrv-.js +1 -0
  33. package/dist/assets/renderMobileApp-BD_wep_I.js +14 -0
  34. package/dist/assets/{runtimeSurface-Bec4O8FD.js → runtimeSurface-CkdOpqo7.js} +1 -1
  35. package/dist/assets/{useInlineCommentController-CHmIm0Rr.js → useInlineCommentController-DVif5Fr8.js} +2 -2
  36. package/dist/assets/usePanelSlide-C_PJmPXW.js +56 -0
  37. package/dist/assets/{useWindowTitle-CE5N_NeU.js → useWindowTitle-BZVlLMCy.js} +3 -3
  38. package/dist/assets/{vendor-base-ui-DwfTg5wD.js → vendor-base-ui-DL8r2fgQ.js} +1 -1
  39. package/dist/assets/{vendor-codemirror-language-data-q1yHU1R9.js → vendor-codemirror-language-data-C-924Z4o.js} +2 -2
  40. package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
  41. package/dist/assets/{web-components-CLirSfwt.js → web-components-BG-6PEN4.js} +1 -1
  42. package/dist/index.html +4 -4
  43. package/dist/mini-chat.html +3 -3
  44. package/dist/mobile.html +3 -3
  45. package/package.json +1 -1
  46. package/server/lib/package-manager.js +0 -34
  47. package/server/lib/pi/routes.js +52 -4
  48. package/server/lib/pi/routes.test.js +14 -6
  49. package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
  50. package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
  51. package/server/lib/pi/session-daemon/runtime-registry.js +3 -0
  52. package/server/lib/pi/session-daemon/session-daemon.js +184 -16
  53. package/server/lib/pi/session-daemon/session-daemon.test.js +310 -5
  54. package/server/lib/pi/session-daemon/supervisor.js +20 -1
  55. package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
  56. package/server/lib/tunnels/DOCUMENTATION.md +6 -7
  57. package/server/lib/tunnels/executable-search.test.js +4 -4
  58. package/server/lib/tunnels/install-help.js +0 -10
  59. package/server/lib/tunnels/install-help.test.js +0 -9
  60. package/server/lib/tunnels/types.js +0 -1
  61. package/dist/assets/SettingsView-JAfC_cBL.js +0 -3
  62. package/dist/assets/SettingsWindow-BBEsjcJE.js +0 -1
  63. package/dist/assets/index-CH7o4-_q.js +0 -8
  64. package/dist/assets/index-o4Eea60G.css +0 -1
  65. package/dist/assets/main-nG4uFyAy.js +0 -133
  66. package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +0 -1
  67. package/dist/assets/renderMobileApp-9qkroKty.js +0 -14
  68. package/dist/assets/usePanelSlide-DCX8qQ4K.js +0 -56
  69. package/server/lib/ngrok-tunnel.js +0 -343
  70. package/server/lib/ngrok-tunnel.test.js +0 -50
  71. package/server/lib/tunnels/providers/ngrok.js +0 -32
@@ -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,62 @@ describe('Pi session daemon spike', () => {
971
1194
  await client.close();
972
1195
  });
973
1196
 
974
- it('preserves assistant messageId for tool executions occurring after message_end', async () => {
1197
+ it('keeps retry attempts attached to the original user turn until Pi settles', async () => {
1198
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
1199
+ const endpoint = testDaemonEndpoint(root);
1200
+ const session = new FakeSession('pi-session-retry');
1201
+ daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
1202
+ await daemon.start();
1203
+ const client = connectClient(endpoint);
1204
+ await client.authenticate();
1205
+ await client.request('sessions.create', { cwd: root });
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
+
1216
+ const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
1217
+ const prematureError = client.next((frame) => frame.event === 'session.error');
1218
+ session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
1219
+ session.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2_000, errorMessage: 'Rate limit exceeded' });
1220
+
1221
+ await expect(retryLifecycle).resolves.toMatchObject({
1222
+ payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
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);
1245
+ await expect(prematureError).rejects.toThrow(/Timed out/);
1246
+
1247
+ session.emit({ type: 'agent_settled' });
1248
+ await retryObserver.close();
1249
+ await client.close();
1250
+ });
1251
+
1252
+ it('keeps ordinary tool errors live and resumes the next assistant in the same user turn', async () => {
975
1253
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
976
1254
  const endpoint = testDaemonEndpoint(root);
977
1255
  const session = new FakeSession('pi-session-tool-seq');
@@ -981,16 +1259,28 @@ describe('Pi session daemon spike', () => {
981
1259
  await client.authenticate();
982
1260
  await client.request('sessions.create', { cwd: root });
983
1261
 
984
- 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');
985
1264
  const messageEndPromise = client.next((frame) => frame.event === 'assistant.message.end');
986
1265
  const toolStartPromise = client.next((frame) => frame.event === 'session.tool.start');
987
1266
  const toolEndPromise = client.next((frame) => frame.event === 'session.tool.end');
988
1267
 
1268
+ session.emit({ type: 'message_start', message: { role: 'user', content: 'run the command', timestamp: 0 } });
989
1269
  session.emit({ type: 'message_start', message: { role: 'assistant', timestamp: 1 } });
990
- 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
+ });
991
1280
  session.emit({ type: 'tool_execution_start', toolCallId: 'tool-call-1', toolName: 'bash', args: { command: 'echo hi' } });
992
- 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 });
993
1282
 
1283
+ const userStart = await userStartPromise;
994
1284
  const messageStart = await messageStartPromise;
995
1285
  const messageEnd = await messageEndPromise;
996
1286
  const toolStart = await toolStartPromise;
@@ -998,11 +1288,26 @@ describe('Pi session daemon spike', () => {
998
1288
 
999
1289
  expect(messageStart.payload.messageId).toMatch(/^assistant-pi-session-tool-seq-\d+$/);
1000
1290
  expect(messageEnd.payload.messageId).toBe(messageStart.payload.messageId);
1291
+ expect(messageEnd.payload.continuing).toBe(true);
1001
1292
  expect(toolStart.payload.messageId).toBe(messageStart.payload.messageId);
1002
1293
  expect(toolStart.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1003
1294
  expect(toolEnd.payload.messageId).toBe(messageStart.payload.messageId);
1004
1295
  expect(toolEnd.payload.partId).toBe(`${messageStart.payload.messageId}:tool:tool-call-1`);
1005
-
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' });
1006
1311
  await client.close();
1007
1312
  });
1008
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
  }
@@ -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');
@@ -5,16 +5,15 @@ This module contains the shared tunnel support surface for PiChamber: provider
5
5
  capability metadata, tunnel types/normalization helpers, cross-platform
6
6
  executable discovery, and dependency install-help metadata.
7
7
 
8
- The actual tunnel start implementations live in
9
- `packages/web/server/lib/cloudflare-tunnel.js` and
10
- `packages/web/server/lib/ngrok-tunnel.js`; CLI orchestration lives in
8
+ The tunnel start implementation lives in
9
+ `packages/web/server/lib/cloudflare-tunnel.js`; CLI orchestration lives in
11
10
  `packages/web/bin/lib/commands-tunnel.js`.
12
11
 
13
12
  > Wiring note: the previous service orchestration entrypoint
14
13
  > (`tunnels/index.js`, `createTunnelService`) was removed as dead code — no
15
14
  > runtime entrypoint registered tunnel routes or started a tunnel service.
16
15
  > The remaining files are consumed by the CLI capability surface and by the
17
- > tunnel provider implementations, not by a tunnel orchestration runtime.
16
+ > Cloudflare tunnel provider implementation, not by a tunnel orchestration runtime.
18
17
 
19
18
  ## Entrypoints and structure
20
19
  - `packages/web/server/lib/tunnels/executable-search.js`: cross-platform executable discovery, including Windows Store app aliases. Consumed by the tunnel providers and `packages/web/server/lib/workspace/host.js`.
@@ -24,10 +23,10 @@ The actual tunnel start implementations live in
24
23
  ## Public exports
25
24
  - `install-help.js` → `getTunnelDependencyInstallInfo(provider, platform)`
26
25
  - `executable-search.js` → `getExecutableSearchDirectories`, `createExecutableSearchEnv`, `findExecutableOnPath`, `resolveExecutableLaunchTarget`
27
- - `types.js` → `TUNNEL_PROVIDER_CLOUDFLARE`, `TUNNEL_PROVIDER_NGROK`, `TUNNEL_MODE_QUICK`, `TUNNEL_MODE_MANAGED_REMOTE`, `TUNNEL_MODE_MANAGED_LOCAL`, `TUNNEL_INTENT_EPHEMERAL_PUBLIC`, `TUNNEL_INTENT_PERSISTENT_PUBLIC`, `TunnelServiceError`
26
+ - `types.js` → `TUNNEL_PROVIDER_CLOUDFLARE`, `TUNNEL_MODE_QUICK`, `TUNNEL_MODE_MANAGED_REMOTE`, `TUNNEL_MODE_MANAGED_LOCAL`, `TUNNEL_INTENT_EPHEMERAL_PUBLIC`, `TUNNEL_INTENT_PERSISTENT_PUBLIC`, `TunnelServiceError`
28
27
 
29
28
  ## Consumers
30
- - `packages/web/bin/lib/cli-tunnel-capabilities.js`: provider capability metadata for CLI tunnel commands.
31
- - `packages/web/server/lib/cloudflare-tunnel.js` / `packages/web/server/lib/ngrok-tunnel.js`: executable discovery, install-help metadata, and provider constants.
29
+ - `packages/web/bin/lib/cli-tunnel-capabilities.js`: provider capability metadata for CLI tunnel commands (Cloudflare only).
30
+ - `packages/web/server/lib/cloudflare-tunnel.js`: executable discovery, install-help metadata, and provider constants.
32
31
  - `packages/web/server/lib/workspace/host.js`: executable discovery.
33
32
 
@@ -36,7 +36,7 @@ describe('getExecutableSearchDirectories', () => {
36
36
 
37
37
  describe('findExecutableOnPath', () => {
38
38
  it('finds Windows Store app execution aliases even when PATH omits WindowsApps', () => {
39
- const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\ngrok.exe';
39
+ const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\cloudflared.exe';
40
40
  const fsLike = {
41
41
  statSync: (candidate) => {
42
42
  if (candidate === aliasPath) {
@@ -47,7 +47,7 @@ describe('findExecutableOnPath', () => {
47
47
  accessSync: () => {},
48
48
  };
49
49
 
50
- const resolved = findExecutableOnPath('ngrok', {
50
+ const resolved = findExecutableOnPath('cloudflared', {
51
51
  platform: 'win32',
52
52
  env: {
53
53
  PATH: 'C:\\Tools',
@@ -63,7 +63,7 @@ describe('findExecutableOnPath', () => {
63
63
 
64
64
  describe('resolveExecutableLaunchTarget', () => {
65
65
  it('returns a Windows launch target with WindowsApps on PATH when stat lookup fails', () => {
66
- const target = resolveExecutableLaunchTarget('ngrok', {
66
+ const target = resolveExecutableLaunchTarget('cloudflared', {
67
67
  platform: 'win32',
68
68
  env: {
69
69
  PATH: 'C:\\Windows\\System32',
@@ -75,7 +75,7 @@ describe('resolveExecutableLaunchTarget', () => {
75
75
  },
76
76
  });
77
77
 
78
- expect(target?.command).toBe('ngrok');
78
+ expect(target?.command).toBe('cloudflared');
79
79
  expect(target?.env.Path).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps');
80
80
  });
81
81
  });
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  TUNNEL_PROVIDER_CLOUDFLARE,
3
- TUNNEL_PROVIDER_NGROK,
4
3
  } from './types.js';
5
4
 
6
5
  const PROVIDER_INSTALL_INFO = {
@@ -13,15 +12,6 @@ const PROVIDER_INSTALL_INFO = {
13
12
  linux: 'Download cloudflared from https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/',
14
13
  },
15
14
  },
16
- [TUNNEL_PROVIDER_NGROK]: {
17
- dependency: 'ngrok',
18
- installUrl: 'https://ngrok.com/download',
19
- commands: {
20
- darwin: 'brew install ngrok',
21
- win32: 'winget install ngrok -s msstore',
22
- linux: 'Download ngrok from https://ngrok.com/download',
23
- },
24
- },
25
15
  };
26
16
 
27
17
  const normalizeInstallPlatform = (platform) => {
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'bun:test';
3
3
  import { getTunnelDependencyInstallInfo } from './install-help.js';
4
4
  import {
5
5
  TUNNEL_PROVIDER_CLOUDFLARE,
6
- TUNNEL_PROVIDER_NGROK,
7
6
  } from './types.js';
8
7
 
9
8
  describe('getTunnelDependencyInstallInfo', () => {
@@ -15,14 +14,6 @@ describe('getTunnelDependencyInstallInfo', () => {
15
14
  expect(info.message).toContain('Cloudflare.cloudflared');
16
15
  });
17
16
 
18
- it('returns Windows ngrok winget guidance', () => {
19
- const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK, 'win32');
20
-
21
- expect(info.dependency).toBe('ngrok');
22
- expect(info.installCommand).toBe('winget install ngrok -s msstore');
23
- expect(info.message).toContain('ngrok -s msstore');
24
- });
25
-
26
17
  it('keeps macOS Homebrew guidance', () => {
27
18
  const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, 'darwin');
28
19
 
@@ -1,5 +1,4 @@
1
1
  export const TUNNEL_PROVIDER_CLOUDFLARE = 'cloudflare';
2
- export const TUNNEL_PROVIDER_NGROK = 'ngrok';
3
2
 
4
3
  export const TUNNEL_MODE_QUICK = 'quick';
5
4
  export const TUNNEL_MODE_MANAGED_REMOTE = 'managed-remote';