@pi-chamber/web 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) 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-BpbMC0et.js} +1 -1
  4. package/dist/assets/{DiagramEditor-ChOor9l9.js → DiagramEditor-dFTMKHuO.js} +1 -1
  5. package/dist/assets/{DiagramView-3hAT86v_.js → DiagramView-DVw_8Px1.js} +1 -1
  6. package/dist/assets/{DiffView-D1dyOiw2.js → DiffView-CkoY1oAM.js} +1 -1
  7. package/dist/assets/{FilesView-BCzghrc7.js → FilesView-C0hyg2pa.js} +6 -6
  8. package/dist/assets/{GitView-CYF1E1p4.js → GitView-D36hpDsb.js} +4 -4
  9. package/dist/assets/{JsonTreeView-CrJO6LNu.js → JsonTreeView-BF8S-bPN.js} +1 -1
  10. package/dist/assets/{MarkdownRendererImpl-BmKjrkGD.js → MarkdownRendererImpl-BVNChdig.js} +1 -1
  11. package/dist/assets/{PatchDiff-DgvPLx6x.js → PatchDiff-B--OaNB1.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-InnT0JVA.js → PierreDiffViewer-0Qkp9ICt.js} +1 -1
  13. package/dist/assets/{SessionSwitcherDropdown-C0OdvirK.js → SessionSwitcherDropdown-cFWgy1td.js} +101 -102
  14. package/dist/assets/SettingsView-D3ZZyjU3.js +4 -0
  15. package/dist/assets/SettingsWindow-BuJCvYoR.js +1 -0
  16. package/dist/assets/{ToolOutputDialog-gzwcb8nI.js → ToolOutputDialog-Bi9BoEe7.js} +4 -4
  17. package/dist/assets/{ToolPartDiffPreview-C45V5bhy.js → ToolPartDiffPreview-DGX7N4zr.js} +1 -1
  18. package/dist/assets/{VirtualizedFileDiff-uKfnkQWb.js → VirtualizedFileDiff-CxraYoDZ.js} +1 -1
  19. package/dist/assets/{collapsible-Bq8sDiKS.js → collapsible-d4-HChej.js} +1 -1
  20. package/dist/assets/{computeVirtualFileMetrics-BPgZ_RSe.js → computeVirtualFileMetrics-BuD14qFL.js} +2 -2
  21. package/dist/assets/{contextFileOpenGuard-CydO7PkI.js → contextFileOpenGuard-DirV-hCs.js} +1 -1
  22. package/dist/assets/{index-BSuNNtBh.js → index-BWmmf5yJ.js} +1 -1
  23. package/dist/assets/{index-CH7o4-_q.js → index-DEYblXCW.js} +7 -7
  24. package/dist/assets/index-DLKBsLKY.css +1 -0
  25. package/dist/assets/{isDiffPlainText-f9b4cKNo.js → isDiffPlainText-LoMmNzmX.js} +1 -1
  26. package/dist/assets/{isFilePlainText-BjESb-gU.js → isFilePlainText-3Hj9i02t.js} +1 -1
  27. package/dist/assets/{main-B508FBXr.js → main-BsszlAdQ.js} +2 -2
  28. package/dist/assets/main-CUvZIN16.js +133 -0
  29. package/dist/assets/{markdownCore-BFmf8a-O.js → markdownCore-sGxk8jrc.js} +2 -2
  30. package/dist/assets/{miniChat-BTlz8IPL.js → miniChat-C6SDQL4G.js} +2 -2
  31. package/dist/assets/{mobile-DBkrRt80.js → mobile-DBW3XDmZ.js} +2 -2
  32. package/dist/assets/renderElectronMiniChatApp-B1_fTdN-.js +1 -0
  33. package/dist/assets/renderMobileApp-CFxZc_1a.js +14 -0
  34. package/dist/assets/{runtimeSurface-Bec4O8FD.js → runtimeSurface-9aJdtQbh.js} +1 -1
  35. package/dist/assets/{useInlineCommentController-CHmIm0Rr.js → useInlineCommentController-Cl--s6vE.js} +2 -2
  36. package/dist/assets/usePanelSlide-D5ffPTGM.js +56 -0
  37. package/dist/assets/{useWindowTitle-CE5N_NeU.js → useWindowTitle-C-AZD713.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-HC9JT4I3.js} +2 -2
  40. package/dist/assets/{web-components-CLirSfwt.js → web-components-skJ_VpnT.js} +4 -4
  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/lib/package-manager.js +0 -34
  46. package/server/lib/pi/routes.js +32 -2
  47. package/server/lib/pi/session-daemon/DOCUMENTATION.md +1 -1
  48. package/server/lib/pi/session-daemon/runtime-registry.js +3 -0
  49. package/server/lib/pi/session-daemon/session-daemon.js +150 -10
  50. package/server/lib/pi/session-daemon/session-daemon.test.js +22 -0
  51. package/server/lib/tunnels/DOCUMENTATION.md +6 -7
  52. package/server/lib/tunnels/executable-search.test.js +4 -4
  53. package/server/lib/tunnels/install-help.js +0 -10
  54. package/server/lib/tunnels/install-help.test.js +0 -9
  55. package/server/lib/tunnels/types.js +0 -1
  56. package/dist/assets/SettingsView-JAfC_cBL.js +0 -3
  57. package/dist/assets/SettingsWindow-BBEsjcJE.js +0 -1
  58. package/dist/assets/index-o4Eea60G.css +0 -1
  59. package/dist/assets/main-nG4uFyAy.js +0 -133
  60. package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +0 -1
  61. package/dist/assets/renderMobileApp-9qkroKty.js +0 -14
  62. package/dist/assets/usePanelSlide-DCX8qQ4K.js +0 -56
  63. package/server/lib/ngrok-tunnel.js +0 -343
  64. package/server/lib/ngrok-tunnel.test.js +0 -50
  65. package/server/lib/tunnels/providers/ngrok.js +0 -32
@@ -135,6 +135,23 @@ const projectUsage = (raw) => {
135
135
  };
136
136
  };
137
137
 
138
+ const sanitizeNavigation = (value) => {
139
+ if (!value || typeof value !== 'object' || typeof value.targetEntryId !== 'string' || value.targetEntryId.length === 0) return null;
140
+ const previousLeafId = value.previousLeafId === null ? null : (typeof value.previousLeafId === 'string' ? value.previousLeafId : null);
141
+ const newLeafId = value.newLeafId === null ? null : (typeof value.newLeafId === 'string' ? value.newLeafId : null);
142
+ if (previousLeafId !== null && typeof previousLeafId !== 'string') return null;
143
+ if (newLeafId !== null && typeof newLeafId !== 'string') return null;
144
+ const editorText = typeof value.editorText === 'string' ? value.editorText : undefined;
145
+ // Limit editorText defensively; Pi prompts are bounded and we never persist this.
146
+ if (editorText !== undefined && editorText.length > 200_000) return null;
147
+ return {
148
+ targetEntryId: value.targetEntryId,
149
+ previousLeafId,
150
+ newLeafId,
151
+ ...(editorText !== undefined ? { editorText } : {}),
152
+ };
153
+ };
154
+
138
155
  const projectSessionDetail = (value) => {
139
156
  if (!value || typeof value !== 'object' || !Array.isArray(value.messages) || !Number.isSafeInteger(value.lastSequence)) throw protocolMismatch();
140
157
  const messages = value.messages.map((item) => {
@@ -495,7 +512,6 @@ export const registerPiRuntimeRoutes = (app, {
495
512
  currentVersion: typeof req.query.currentVersion === 'string' ? req.query.currentVersion : undefined,
496
513
  appType: typeof req.query.appType === 'string' ? req.query.appType : undefined,
497
514
  platform: typeof req.query.platform === 'string' ? req.query.platform : undefined,
498
- reportUsage: req.query.reportUsage === 'true',
499
515
  }));
500
516
  } catch {
501
517
  res.status(503).json({ error: 'Update check unavailable' });
@@ -567,6 +583,16 @@ export const registerPiRuntimeRoutes = (app, {
567
583
  }
568
584
  });
569
585
 
586
+ app.post('/api/pi/providers/refresh', async (req, res) => {
587
+ const directory = typeof req.body?.directory === 'string' && req.body.directory.length > 0 ? req.body.directory : undefined;
588
+ try {
589
+ const result = await getDaemonRuntime(getPiSessionDaemonRuntime).request('providers.refresh', directory ? { directory } : undefined);
590
+ res.json(projectProviders(result));
591
+ } catch (error) {
592
+ writeDaemonError(res, error);
593
+ }
594
+ });
595
+
570
596
  app.get('/api/pi/providers/:providerId/config', async (req, res) => {
571
597
  const providerId = req.params.providerId;
572
598
  if (typeof providerId !== 'string' || providerId.length === 0) {
@@ -891,7 +917,11 @@ export const registerPiRuntimeRoutes = (app, {
891
917
 
892
918
  app.post('/api/pi/sessions/:sessionId/navigate', async (req, res) => {
893
919
  const result = await requestSessionOperation(req, res, getPiSessionDaemonRuntime, 'sessions.navigate', { messageId: req.body?.messageId });
894
- if (result !== undefined) res.json(projectSessionDetail(result));
920
+ if (result === undefined) return;
921
+ const detail = projectSessionDetail(result);
922
+ const navigation = sanitizeNavigation(result.navigation);
923
+ if (navigation) detail.navigation = navigation;
924
+ res.json(detail);
895
925
  });
896
926
 
897
927
  for (const [suffix, command] of [['fork', 'sessions.fork'], ['clone', 'sessions.clone']]) {
@@ -27,7 +27,7 @@ This module is the Pi-owned session-daemon foundation. A detached local daemon p
27
27
  - Frames are LF-delimited JSON. They have a 1 MiB maximum buffered size. A malformed, oversized, unauthenticated, or invalid request closes the connection.
28
28
  - The private core commands are `runtime.health`, `projects.list/select`, `providers.list/config.get/models.set/status/login/login.respond/login.status/logout`, `settings.get/set`, `resources.list/update/prompts.create/prompts.delete`, and session list/create/open/rename/delete/tree/navigate/fork/clone/prompt/steer/followUp/abort/model/thinking/compact operations. Resource lists use opaque IDs and never disclose server paths. Only top-level Pi prompt templates and applicable AGENTS.md sources are editable; package/path resources and skills are browse-only. Resource writes are rejected while a session streams, use an atomic replace, then recreate the idle runtime. Project trust changes are persisted in Pi trust.json and likewise recreate an idle runtime; extensions remain disabled regardless of trust. Provider login submits API keys only across authenticated private IPC or brokers Pi's OAuth/device/manual-code interaction; public projections contain only opaque login IDs, prompts, URLs/device codes, model catalog/configuration, and stable states, never credentials or write-only headers. `providers.models.set` is rejected while a session streams, atomically persists Pi's `models.json`, then rehydrates the idle runtime so the catalog is authoritative immediately. `providers.list` projects only catalog and authentication-status metadata, including Pi-supported `thinkingLevels` (`null` map entries hidden, `xhigh`/`max` opt-in). Session thinking overrides are clamped to the live model's supported levels. `sessions.list` checks each cwd JSONL header (not the full transcript) so a project with large session logs cannot spend tens of seconds on startup. SDK `SessionManager.list` is not used for that path; `listPiSessionJsonlDirectory` reads a header, a bounded prefix for the first user prompt, and a tail for the latest rename. Concurrent lists of the same directory share one in-flight result. Concurrent `sessions.open` of the same id share one in-flight runtime create; a duplicate that finishes later disposes itself and returns the registered runtime instead of `SESSION_RUNTIME_CONFLICT`. Full-file JSONL validation stays on open of a chosen session. The browser explicitly selects an existing user-chosen project directory before listing it; the daemon's startup cwd is an internal fallback, not a user-visible default project. Unnamed persisted sessions use their first prompt as a readable list title when available. `sessions.open` and `getSession` project the last assistant `provider`/`model`/`thinkingLevel` onto the session record so reopening a chat keeps that cache identity. Session selection validates a persisted JSONL path before Pi opens it; inactive mutations are never directed by a browser-supplied path. `sessions.open` resolves an id by Pi's `timestamp_<id>.jsonl` filename first so a missing deep link 404s without listing or fully reading every transcript in every cwd; SDK list and header scan remain the fallback for non-standard names. Directory-wide JSONL checks on list/create/startup are header-only; full-file validation stays on open of a chosen transcript. Attachment paths originate in the server-local upload map and cross only private IPC into Pi's tool context. Pi may persist or echo those paths in its authoritative JSONL transcript, so every public projection redacts them recursively: session titles/previews, user and assistant text/thinking, tool arguments, errors, snapshots, and live message events never expose the server-local path. The daemon maps Pi message lifecycle, deltas, tools, queue changes, model/thinking, compaction, interruption state, and session title updates (`session.updated` on create-with-title, first-prompt auto-title, and rename) to canonical events. Tool events carry redacted input arguments, cumulative partial-output snapshots, final output, error text, renderer metadata (edit diffs, truncation notes), and start/end timestamps. Persisted `toolResult` entries merge into their assistant `toolCall` parts as output/error/end time so hydrated sessions render complete tool cards; tool-result entries never surface as standalone messages. Assistant message projections and start events carry the owning user-message id so the restored turn renderer can group and display replies; all assistant entries in a tool loop remain attached to the latest user turn. Assistant message projections also pass through Pi's per-turn `Usage` (input, output, cacheRead, cacheWrite, totalTokens, and the same five numeric cost fields). The daemon sanitizes the raw payload through `projectUsage` before publication: every numeric must be finite and non-negative, the whole object is omitted when any field is missing or wrong type, and unknown keys are never copied. The `assistant.message.end` event carries the same sanitized usage so the last turn updates without waiting for a full `getSession` refetch. The public route whitelist re-applies the same sanitizer on each projection so a stale or malformed payload from a different daemon version cannot surface as `NaN` or `Infinity` in the reducer.
29
29
  - Each event and reconnect snapshot has a monotonic sequence. A reconnect receives a bounded contiguous replay after its cursor when available, otherwise an authoritative `session.snapshot`; unavailable runtimes are not represented as empty sessions.
30
- - Prompt, steer, and follow-up requests acknowledge acceptance immediately after Pi accepts the call; the daemon owns the still-running send promise and publishes any asynchronous rejection as `session.error`. A later prompt on the same session invalidates that rejection so it cannot error the new turn. If the rejected send left Pi streaming, the daemon aborts it. Steer/follow-up against an idle session starts a new prompt instead of `SESSION_NOT_RUNNING`. Agent completion is never held behind the 30-second public/private request budgets.
30
+ - Prompt, steer, and follow-up requests acknowledge acceptance immediately after Pi accepts the call; the daemon owns the still-running send promise and publishes any asynchronous rejection as `session.error`. A later prompt on the same session invalidates that rejection so it cannot error the new turn. If the rejected send left Pi streaming, the daemon aborts it. Steer/follow-up against an idle session starts a new prompt instead of `SESSION_NOT_RUNNING`. Agent completion is never held behind the 30-second public/private request budgets. Pi retryable provider failures remain live: an `agent_end` with `willRetry` does not publish terminal `session.error`, and `auto_retry_start` projects Pi's attempt, next-attempt time, and redacted error text through `session.lifecycle: retry`. `agent_settled` remains the authoritative idle boundary after retry success, exhaustion, or cancellation.
31
31
  - Registry keys combine the SDK session ID with its cwd. A replacement subscription is rebound to the replacement session so old-session events cannot be published under the new identity. A collision is a visible error; it never replaces or disposes an unrelated runtime.
32
32
  - After `agent_settled`, the daemon disposes an idle runtime after five minutes (injectable for tests). Disposal never deletes Pi JSONL; the daemon remembers the selected session file and recreates that runtime on the next private prompt. A new agent start cancels a pending disposal.
33
33
  - A startup validation failure writes only a stable failure code to the owner-only non-secret state sidecar. It never records a transcript, session path, or credential. The supervisor surfaces that code as unavailable instead of claiming an empty or idle session.
@@ -140,6 +140,9 @@ export function createSessionRuntimeRegistry({ onSessionEvent = () => {} } = {})
140
140
  register,
141
141
  dispose,
142
142
  disposeAll,
143
+ listAll() {
144
+ return [...recordsByKey.values()].map((record) => record.runtime);
145
+ },
143
146
  get size() {
144
147
  return recordsByKey.size;
145
148
  },
@@ -631,7 +631,11 @@ export function createSessionDaemon({
631
631
  };
632
632
 
633
633
  const projectMessageEntries = (session, targetDir = activeDirectory || cwd) => {
634
- const persisted = session?.sessionManager?.getEntries?.();
634
+ // Use the active branch, not the full file. `getEntries()` returns every
635
+ // entry ever written, so a bare `branch()`/`resetLeaf()` would appear to
636
+ // do nothing. `getBranch()` follows the current leaf and is what
637
+ // `navigateTree` and `buildSessionContext` use for the model context.
638
+ const persisted = session?.sessionManager?.getBranch?.() ?? session?.sessionManager?.getEntries?.();
635
639
  const entries = liveProjectionEntries(session, Array.isArray(persisted) ? persisted : []);
636
640
  if (entries.length === 0) return [];
637
641
  const streaming = session?.isStreaming === true;
@@ -820,6 +824,84 @@ export function createSessionDaemon({
820
824
  return { providers: [...providers.values()] };
821
825
  };
822
826
 
827
+ let refreshProvidersInflight = null;
828
+ const refreshProviders = async (requestedDirectory) => {
829
+ if (refreshProvidersInflight) return refreshProvidersInflight;
830
+ const task = (async () => {
831
+ const signal = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(15_000) : undefined;
832
+ const runtimes = new Set();
833
+ for (const services of servicesCache.values()) {
834
+ const mr = services?.modelRuntime;
835
+ if (mr) runtimes.add(mr);
836
+ }
837
+ if (runtime?.session?.modelRuntime) runtimes.add(runtime.session.modelRuntime);
838
+ if (runtime?.services?.modelRuntime) runtimes.add(runtime.services.modelRuntime);
839
+ if (runtimeRegistry?.listAll) {
840
+ try {
841
+ for (const tracked of runtimeRegistry.listAll()) {
842
+ const mr = tracked?.session?.modelRuntime ?? tracked?.services?.modelRuntime;
843
+ if (mr) runtimes.add(mr);
844
+ }
845
+ } catch {}
846
+ }
847
+ if (runtimes.size === 0) {
848
+ const active = await ensureRuntime(requestedDirectory ? await resolveDirectory(requestedDirectory) : undefined);
849
+ const mr = active?.session?.modelRuntime ?? active?.services?.modelRuntime;
850
+ if (mr) runtimes.add(mr);
851
+ }
852
+ const dummyMap = new Map();
853
+ for (const mr of runtimes) {
854
+ try {
855
+ const providers = typeof mr.getProviders === 'function' ? mr.getProviders() : [];
856
+ for (const provider of providers) {
857
+ const auth = mr.getProviderAuthStatus?.(provider.id);
858
+ if (auth?.configured === true) continue;
859
+ try {
860
+ await mr.setRuntimeApiKey(provider.id, 'pichamber-catalog-refresh');
861
+ let list = dummyMap.get(mr);
862
+ if (!list) { list = []; dummyMap.set(mr, list); }
863
+ list.push(provider.id);
864
+ } catch {}
865
+ }
866
+ } catch {}
867
+ }
868
+ const errors = new Map();
869
+ let aborted = false;
870
+ try {
871
+ await Promise.all([...runtimes].map(async (mr) => {
872
+ try {
873
+ const result = await mr.refresh({ allowNetwork: true, force: true, ...(signal ? { signal } : {}) });
874
+ if (result?.aborted) aborted = true;
875
+ if (result?.errors) {
876
+ for (const [providerId, err] of result.errors) errors.set(providerId, err);
877
+ }
878
+ } catch (error) {
879
+ if (error?.code === 'PI_MODEL_CONFIG_INVALID') throw error;
880
+ errors.set('_global', error);
881
+ }
882
+ }));
883
+ } finally {
884
+ for (const [mr, ids] of dummyMap.entries()) {
885
+ for (const id of ids) {
886
+ try { await mr.removeRuntimeApiKey(id); } catch {}
887
+ }
888
+ }
889
+ }
890
+ if (errors.has('_global') && runtimes.size > 0) {
891
+ const globalError = errors.get('_global');
892
+ if (globalError?.code === 'PI_MODEL_CONFIG_INVALID') {
893
+ throw new SessionDaemonProtocolError('PI_MODEL_CONFIG_INVALID', 'Pi models configuration is invalid.');
894
+ }
895
+ }
896
+ const catalog = await listProviders(requestedDirectory);
897
+ return catalog;
898
+ })().finally(() => {
899
+ refreshProvidersInflight = null;
900
+ });
901
+ refreshProvidersInflight = task;
902
+ return task;
903
+ };
904
+
823
905
  const getProviderConfiguration = async (providerId) => {
824
906
  if (typeof providerId !== 'string' || providerId.length === 0 || providerId.length > 256) {
825
907
  throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested provider is invalid.');
@@ -1101,9 +1183,21 @@ export function createSessionDaemon({
1101
1183
  if (!loader || typeof loader.getSkills !== 'function' || typeof loader.getPrompts !== 'function' || typeof loader.getAgentsFiles !== 'function') {
1102
1184
  throw new SessionDaemonProtocolError('DAEMON_REQUEST_FAILED', 'Pi resource discovery is unavailable.');
1103
1185
  }
1104
- const skills = loader.getSkills().skills.map((skill) => ({
1105
- id: resourceId('skill', skill.filePath),
1106
- kind: 'skill', name: skill.name, ...(skill.description ? { description: skill.description } : {}), location: resourceLocation(skill.sourceInfo), editable: false,
1186
+ const skills = await Promise.all(loader.getSkills().skills.map(async (skill) => {
1187
+ let content = '';
1188
+ try {
1189
+ content = await readFile(skill.filePath, 'utf8');
1190
+ } catch {}
1191
+ return {
1192
+ id: resourceId('skill', skill.filePath),
1193
+ kind: 'skill',
1194
+ name: skill.name,
1195
+ ...(skill.description ? { description: skill.description } : {}),
1196
+ location: resourceLocation(skill.sourceInfo),
1197
+ editable: false,
1198
+ ...(content ? { content } : {}),
1199
+ filePath: skill.filePath,
1200
+ };
1107
1201
  }));
1108
1202
  const prompts = loader.getPrompts().prompts.map((prompt) => ({
1109
1203
  id: resourceId('prompt', prompt.filePath),
@@ -1642,11 +1736,19 @@ export function createSessionDaemon({
1642
1736
  const finalMessage = event.messages?.at?.(-1);
1643
1737
  if (finalMessage?.role === 'assistant' && finalMessage.stopReason === 'aborted') {
1644
1738
  publish('session.interrupted', { reason: 'user-abort', streaming: false }, sessionId, directory);
1645
- } else if (finalMessage?.role === 'assistant' && typeof finalMessage.errorMessage === 'string') {
1739
+ } else if (finalMessage?.role === 'assistant' && typeof finalMessage.errorMessage === 'string' && event.willRetry !== true) {
1646
1740
  publish('session.error', { code: 'ASSISTANT_ERROR', message: redactAttachmentPaths(finalMessage.errorMessage) }, sessionId, directory);
1647
1741
  }
1648
1742
  break;
1649
1743
  }
1744
+ case 'auto_retry_start':
1745
+ publish('session.lifecycle', {
1746
+ state: 'retry',
1747
+ attempt: event.attempt,
1748
+ next: Date.now() + event.delayMs,
1749
+ message: redactAttachmentPaths(event.errorMessage),
1750
+ }, sessionId, directory);
1751
+ break;
1650
1752
  case 'agent_settled':
1651
1753
  publish('session.lifecycle', { state: 'idle' }, sessionId, directory);
1652
1754
  scheduleIdleDisposal(sessionId);
@@ -1684,7 +1786,7 @@ export function createSessionDaemon({
1684
1786
  'projects.list', 'projects.select', 'sessions.list', 'sessions.create', 'sessions.open', 'sessions.rename', 'sessions.delete',
1685
1787
  'sessions.tree', 'sessions.navigate', 'sessions.fork', 'sessions.clone', 'sessions.prompt',
1686
1788
  'sessions.steer', 'sessions.followUp', 'sessions.abort', 'sessions.setModel',
1687
- 'sessions.setThinking', 'sessions.compact', 'providers.list', 'providers.config.get', 'providers.models.set', 'providers.status', 'providers.login',
1789
+ 'sessions.setThinking', 'sessions.compact', 'providers.list', 'providers.refresh', 'providers.config.get', 'providers.models.set', 'providers.status', 'providers.login',
1688
1790
  'providers.login.respond', 'providers.login.status', 'providers.logout', 'settings.get', 'settings.set',
1689
1791
  'resources.list', 'resources.update', 'resources.prompts.create', 'resources.prompts.delete',
1690
1792
  ],
@@ -1716,6 +1818,11 @@ export function createSessionDaemon({
1716
1818
  writeFrame(socket, { protocolVersion: PROTOCOL_VERSION, kind: 'response', requestId: message.requestId, result });
1717
1819
  return;
1718
1820
  }
1821
+ case 'providers.refresh': {
1822
+ const result = await refreshProviders(message.payload?.directory);
1823
+ writeFrame(socket, { protocolVersion: PROTOCOL_VERSION, kind: 'response', requestId: message.requestId, result });
1824
+ return;
1825
+ }
1719
1826
  case 'providers.config.get': {
1720
1827
  const result = await getProviderConfiguration(message.payload?.providerId);
1721
1828
  writeFrame(socket, { protocolVersion: PROTOCOL_VERSION, kind: 'response', requestId: message.requestId, result });
@@ -1817,11 +1924,44 @@ export function createSessionDaemon({
1817
1924
  }
1818
1925
  case 'sessions.navigate': {
1819
1926
  const activeRuntime = await activateSession(message.payload?.sessionId, message.payload?.directory || message.payload?.cwd);
1820
- const messageId = message.payload?.messageId;
1927
+ let messageId = message.payload?.messageId;
1821
1928
  if (typeof messageId !== 'string' || messageId.length === 0) throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The requested tree entry is invalid.');
1822
- const result = await activeRuntime.session.navigateTree(messageId);
1823
- if (result.cancelled) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'Pi cancelled tree navigation.');
1824
- writeFrame(socket, { protocolVersion: PROTOCOL_VERSION, kind: 'response', requestId: message.requestId, result: projectActiveSession(activeRuntime, activeRuntime.cwd) });
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
+ }
1936
+ 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;
1956
+ if (result?.cancelled) throw new SessionDaemonProtocolError('SESSION_TREE_NOT_FOUND', 'Pi cancelled tree navigation.');
1957
+ const newLeafId = activeRuntime.session.sessionManager?.getLeafId?.() ?? null;
1958
+ const navigation = {
1959
+ targetEntryId: messageId,
1960
+ previousLeafId: typeof previousLeafId === 'string' ? previousLeafId : null,
1961
+ newLeafId: typeof newLeafId === 'string' ? newLeafId : null,
1962
+ ...(typeof result?.editorText === 'string' && result.editorText.length > 0 ? { editorText: result.editorText } : {}),
1963
+ };
1964
+ writeFrame(socket, { protocolVersion: PROTOCOL_VERSION, kind: 'response', requestId: message.requestId, result: { ...projectActiveSession(activeRuntime, activeRuntime.cwd), navigation } });
1825
1965
  return;
1826
1966
  }
1827
1967
  case 'sessions.fork':
@@ -971,6 +971,28 @@ describe('Pi session daemon spike', () => {
971
971
  await client.close();
972
972
  });
973
973
 
974
+ it('keeps retryable provider failures in retry lifecycle until Pi exhausts retry', async () => {
975
+ const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-retry-'));
976
+ const endpoint = testDaemonEndpoint(root);
977
+ const session = new FakeSession('pi-session-retry');
978
+ daemon = createSessionDaemon({ endpoint, credential, cwd: root, createRuntime: async () => ({ session, async dispose() {} }) });
979
+ await daemon.start();
980
+ const client = connectClient(endpoint);
981
+ await client.authenticate();
982
+ await client.request('sessions.create', { cwd: root });
983
+
984
+ const retryLifecycle = client.next((frame) => frame.event === 'session.lifecycle' && frame.payload.state === 'retry');
985
+ const prematureError = client.next((frame) => frame.event === 'session.error');
986
+ session.emit({ type: 'agent_end', willRetry: true, messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'Rate limit exceeded' }] });
987
+ session.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2_000, errorMessage: 'Rate limit exceeded' });
988
+
989
+ await expect(retryLifecycle).resolves.toMatchObject({
990
+ payload: { state: 'retry', attempt: 1, message: 'Rate limit exceeded' },
991
+ });
992
+ await expect(prematureError).rejects.toThrow(/Timed out/);
993
+ await client.close();
994
+ });
995
+
974
996
  it('preserves assistant messageId for tool executions occurring after message_end', async () => {
975
997
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-daemon-'));
976
998
  const endpoint = testDaemonEndpoint(root);
@@ -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';