@pi-chamber/web 0.3.1 → 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 (59) 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-C336377Z.js → CommitSection-DLIvjYbM.js} +1 -1
  4. package/dist/assets/{DiagramEditor-gHLCXHtr.js → DiagramEditor-Tw3ZWHML.js} +1 -1
  5. package/dist/assets/{DiagramView-DlHzRRBc.js → DiagramView-CK6R7taL.js} +1 -1
  6. package/dist/assets/{DiffView-5wHwK-lg.js → DiffView-vrxi5oWp.js} +2 -2
  7. package/dist/assets/{FilesView-5bswI3JZ.js → FilesView-BD_h3EVr.js} +6 -6
  8. package/dist/assets/{GitView-BfykGpGh.js → GitView-BwNKqK0K.js} +4 -4
  9. package/dist/assets/{JsonTreeView-HVwQS7qG.js → JsonTreeView-CmY7QgEX.js} +1 -1
  10. package/dist/assets/{MarkdownRendererImpl-kjd4e0N8.js → MarkdownRendererImpl-CpI-25Xk.js} +1 -1
  11. package/dist/assets/{PatchDiff-Dlm4RU8n.js → PatchDiff-CrIWbYj7.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-St32rXlD.js → PierreDiffViewer-B410mdbq.js} +2 -2
  13. package/dist/assets/{SessionSwitcherDropdown-D5bWfZNA.js → SessionSwitcherDropdown-DROjbyIu.js} +93 -93
  14. package/dist/assets/SettingsView-CsJItYZh.js +4 -0
  15. package/dist/assets/{SettingsWindow-Ck_uhTnx.js → SettingsWindow-C9s4AX72.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-BCONUv3r.js → ToolOutputDialog-b_R4tAZu.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-BdVuk2kc.js → ToolPartDiffPreview-CfnliaE9.js} +1 -1
  18. package/dist/assets/{VirtualizedFileDiff-BsAZM0jo.js → VirtualizedFileDiff-CQz2fSZI.js} +4 -4
  19. package/dist/assets/{collapsible-Wnmq-13H.js → collapsible-CR4U9tjw.js} +1 -1
  20. package/dist/assets/{computeVirtualFileMetrics-skbyL2Az.js → computeVirtualFileMetrics-C7vXPUYT.js} +2 -2
  21. package/dist/assets/{contextFileOpenGuard-BsxQITMZ.js → contextFileOpenGuard-DtTHRnq1.js} +1 -1
  22. package/dist/assets/{index-8TL2HlaR.js → index-BAqpUCjs.js} +4 -4
  23. package/dist/assets/{index-Bfp6eYap.js → index-DZG82DE1.js} +1 -1
  24. package/dist/assets/{isDiffPlainText-dZkfk3yG.js → isDiffPlainText-zTq-4A04.js} +1 -1
  25. package/dist/assets/{isFilePlainText-UL_tMQZx.js → isFilePlainText-CLgIdzE5.js} +1 -1
  26. package/dist/assets/{main-CHQfoyRP.js → main-BVWtJYSk.js} +2 -2
  27. package/dist/assets/main-DXYHsqzA.js +133 -0
  28. package/dist/assets/{markdownCore-qX3mJFkL.js → markdownCore-CN0pApQE.js} +2 -2
  29. package/dist/assets/{miniChat-3QtCNwHl.js → miniChat-BrbRq5NQ.js} +2 -2
  30. package/dist/assets/{mobile-B_CyfRAX.js → mobile-DTPtCn5N.js} +2 -2
  31. package/dist/assets/{renderElectronMiniChatApp-OToslrv-.js → renderElectronMiniChatApp-CM8qBaye.js} +1 -1
  32. package/dist/assets/{renderMobileApp-BD_wep_I.js → renderMobileApp-KS_pt-qc.js} +4 -4
  33. package/dist/assets/{runtimeSurface-CkdOpqo7.js → runtimeSurface-CkHYYg0E.js} +1 -1
  34. package/dist/assets/{useInlineCommentController-DVif5Fr8.js → useInlineCommentController-CwNNjgc7.js} +2 -2
  35. package/dist/assets/usePanelSlide-DkorgQJ5.js +56 -0
  36. package/dist/assets/{useWindowTitle-BZVlLMCy.js → useWindowTitle-B9wt_3ZQ.js} +3 -3
  37. package/dist/assets/{vendor-codemirror-language-data-C-924Z4o.js → vendor-codemirror-language-data-x89A7Wy0.js} +2 -2
  38. package/dist/assets/{web-components-BG-6PEN4.js → web-components-BEPfE4kU.js} +4 -4
  39. package/dist/index.html +3 -3
  40. package/dist/mini-chat.html +2 -2
  41. package/dist/mobile.html +2 -2
  42. package/package.json +1 -1
  43. package/server/index.js +33 -0
  44. package/server/lib/pi/routes.js +5 -1
  45. package/server/lib/pi/session-daemon/DOCUMENTATION.md +1 -1
  46. package/server/lib/pi/session-daemon/session-daemon.js +111 -9
  47. package/server/lib/pi/session-daemon/session-daemon.test.js +75 -0
  48. package/server/lib/pi/settings-store.js +17 -0
  49. package/server/lib/pi/settings-store.test.js +33 -0
  50. package/server/lib/server/core-routes.js +36 -4
  51. package/server/lib/server/tunnel-service.DOCUMENTATION.md +74 -0
  52. package/server/lib/server/tunnel-service.js +228 -0
  53. package/server/lib/server/tunnel-service.test.js +61 -0
  54. package/server/lib/ui-auth/DOCUMENTATION.md +2 -0
  55. package/server/lib/ui-auth/ui-auth.js +5 -1
  56. package/server/lib/ui-auth/ui-auth.test.js +17 -0
  57. package/dist/assets/SettingsView-FGe5TEQq.js +0 -4
  58. package/dist/assets/main-DbnYXZeK.js +0 -133
  59. package/dist/assets/usePanelSlide-C_PJmPXW.js +0 -56
package/dist/mobile.html CHANGED
@@ -63,12 +63,12 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-B_CyfRAX.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-DTPtCn5N.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DYyjh7Q0.js">
70
70
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-CnsBKjP5.js">
71
- <link rel="modulepreload" crossorigin href="/assets/index-8TL2HlaR.js">
71
+ <link rel="modulepreload" crossorigin href="/assets/index-BAqpUCjs.js">
72
72
  <link rel="modulepreload" crossorigin href="/assets/vendor-katex-CHD9BdDO.js">
73
73
  <link rel="stylesheet" crossorigin href="/assets/index-riABQmQ_.css">
74
74
  <link rel="stylesheet" crossorigin href="/assets/vendor-katex-Cp579UoJ.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-chamber/web",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
package/server/index.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
11
11
  import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
12
12
  import { resolvePiChamberDataDir } from './lib/pichamber-data-dir.js';
13
+ import { createTunnelService } from './lib/server/tunnel-service.js';
13
14
  import { registerPiRuntimeRoutes } from './lib/pi/routes.js';
14
15
  import { registerWorkspaceIntegrations } from './lib/workspace/host.js';
15
16
  import { createPiSessionDaemonSupervisor } from './lib/pi/session-daemon/supervisor.js';
@@ -114,6 +115,15 @@ export async function startWebUiServer(options = {}) {
114
115
  const tunnelAuthController = createTunnelAuth();
115
116
  const uiAuthController = createUiAuth({ password: uiPassword, readSettingsFromDiskMigrated: async () => ({}) , clientAuthController: remoteClientAuthRuntime });
116
117
  const piSessionDaemonRuntime = createPiSessionDaemonSupervisor({ dataDir: PICHAMBER_DATA_DIR });
118
+ const tunnelService = createTunnelService({
119
+ dataDir: PICHAMBER_DATA_DIR,
120
+ getPort: () => {
121
+ const address = server.address();
122
+ return typeof address === 'object' && address ? address.port : null;
123
+ },
124
+ tunnelAuthController,
125
+ getServerLabel: () => os.hostname() || 'PiChamber',
126
+ });
117
127
  let stopped = false;
118
128
 
119
129
  app.set('trust proxy', true);
@@ -157,6 +167,29 @@ export async function startWebUiServer(options = {}) {
157
167
  getServerLabel: () => os.hostname() || 'PiChamber',
158
168
  });
159
169
  registerPiRuntimeRoutes(app, { getPiSessionDaemonRuntime: () => piSessionDaemonRuntime });
170
+ // Cloudflare Tunnel external access (manual token + quick modes).
171
+ const requireTunnelAuth = (req, res, next) => uiAuthController.requireAuth(req, res, next);
172
+ app.get('/api/pichamber/tunnel/status', requireTunnelAuth, async (_req, res) => {
173
+ try { res.json(await tunnelService.getStatus()); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to get tunnel status' }); }
174
+ });
175
+ app.get('/api/pichamber/tunnel/check', requireTunnelAuth, async (req, res) => {
176
+ try { res.json(await tunnelService.check(req.query?.provider)); } catch (error) { res.status(500).json({ error: error?.message || 'Tunnel check failed' }); }
177
+ });
178
+ app.get('/api/pichamber/tunnel/providers', requireTunnelAuth, async (_req, res) => {
179
+ res.json({ providers: [{ provider: 'cloudflare', modes: [{ key: 'quick' }, { key: 'managed-remote' }, { key: 'managed-local' }] }] });
180
+ });
181
+ app.post('/api/pichamber/tunnel/start', express.json({ limit: '64kb' }), requireTunnelAuth, async (req, res) => {
182
+ try { const result = await tunnelService.start(req.body ?? {}); res.json(result); } catch (error) { const code = error?.code === 'missing_dependency' ? 400 : error?.code === 'validation_error' ? 422 : 500; res.status(code).json({ ok: false, error: error?.message || 'Failed to start tunnel', code: error?.code }); }
183
+ });
184
+ app.post('/api/pichamber/tunnel/stop', requireTunnelAuth, async (_req, res) => {
185
+ try { res.json(await tunnelService.stop()); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to stop tunnel' }); }
186
+ });
187
+ app.put('/api/pichamber/tunnel/managed-remote-token', express.json({ limit: '64kb' }), requireTunnelAuth, async (req, res) => {
188
+ try { res.json(await tunnelService.saveManagedRemoteToken(req.body ?? {})); } catch (error) { const code = error?.code === 'validation_error' ? 400 : 500; res.status(code).json({ ok: false, error: error?.message || 'Failed to save token' }); }
189
+ });
190
+ app.get('/api/pichamber/tunnel/doctor', requireTunnelAuth, async (req, res) => {
191
+ try { const status = await tunnelService.getStatus(); const checkResult = await tunnelService.check(); res.json({ ok: true, status, check: checkResult, query: req.query }); } catch (error) { res.status(500).json({ ok: false, error: error?.message || 'Doctor failed' }); }
192
+ });
160
193
  registerWorkspaceIntegrations({ app, server, express, uiAuthController });
161
194
  registerStaticRoutes(app, { apiOnly });
162
195
 
@@ -219,6 +219,8 @@ const projectSessionDetail = (value) => {
219
219
  isStreaming,
220
220
  lifecycle,
221
221
  ...(retry ? { retry } : {}),
222
+ ...(Number.isFinite(value.runStartedAt) ? { runStartedAt: Math.floor(value.runStartedAt) } : {}),
223
+ ...(Number.isFinite(value.serverNow) ? { serverNow: Math.floor(value.serverNow) } : {}),
222
224
  };
223
225
  };
224
226
 
@@ -380,12 +382,14 @@ const projectEventFrame = (frame) => {
380
382
  ...(typeof snapshot.thinking === 'string' ? { thinking: snapshot.thinking } : {}),
381
383
  ...(typeof snapshot.lastText === 'string' ? { lastText: snapshot.lastText } : {}),
382
384
  ...(typeof snapshot.lastThinking === 'string' ? { lastThinking: snapshot.lastThinking } : {}),
385
+ ...(Number.isFinite(snapshot.runStartedAt) ? { runStartedAt: Math.floor(snapshot.runStartedAt) } : {}),
386
+ ...(Number.isFinite(snapshot.serverNow) ? { serverNow: Math.floor(snapshot.serverNow) } : {}),
383
387
  lastSequence: Number.isSafeInteger(snapshot.lastSequence) ? snapshot.lastSequence : frame.sequence,
384
388
  } } };
385
389
  }
386
390
  case 'session.lifecycle': {
387
391
  const retry = frame.payload.state === 'retry' ? projectRetryInfo(frame.payload) : null;
388
- return { ...common, payload: { state: frame.payload.state, ...(retry ?? {}) } };
392
+ return { ...common, payload: { state: frame.payload.state, ...(retry ?? {}), ...(Number.isFinite(frame.payload.runStartedAt) ? { runStartedAt: Math.floor(frame.payload.runStartedAt) } : {}), ...(Number.isFinite(frame.payload.serverNow) ? { serverNow: Math.floor(frame.payload.serverNow) } : {}) } };
389
393
  }
390
394
  case 'session.updated': {
391
395
  if (typeof frame.payload.title !== 'string') return null;
@@ -25,7 +25,7 @@ This module is the Pi-owned session-daemon foundation. A detached local daemon p
25
25
  - The public health route deliberately whitelists its response fields. It never returns a daemon endpoint, credential, PID, cwd, agent directory, or transcript data. Public transcript and event projections preserve daemon-redacted assistant error messages so the UI can distinguish provider/runtime failures instead of reducing every failure to `ASSISTANT_ERROR`.
26
26
  - A client must send an `authenticate` frame carrying the host-private daemon credential before it can issue a request. Credentials are never logged or returned.
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
- - 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 and every retry/compaction continuation remain attached to the latest user turn until `agent_settled` closes the run. 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.
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. Persisted projection first checks for the attachment marker, then redacts matching tokens with a delimiter scan whose work stays linear for large encoded strings and tool payloads. 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 and every retry/compaction continuation remain attached to the latest user turn until `agent_settled` closes the run. 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
  - Live `message_start` events use daemon-generated browser message IDs because Pi has not appended the corresponding session entry yet. Pi agent-core emits streaming assistant starts as shallow copies, not as the finalized object it later persists. At `message_end`, `message-entry-aliases.js` therefore rebinds the synthetic ID to the finalized event object, then maps that object by identity to Pi's appended entry in a microtask. `sessions.navigate` and `sessions.fork` resolve the mapping after stripping any part suffix. Alias keys include cwd and session ID, survive idle runtime disposal, and clear only on explicit session deletion or daemon shutdown. Hydrated message projections continue to use the Pi entry ID directly.
30
30
  - 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.
31
31
  - 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`. Retry metadata is retained in reconnect and `getSession` snapshots until the next `agent_start` begins the attempt. `agent_settled` remains the authoritative idle boundary after retry success, exhaustion, or cancellation and clears the run's user/assistant ownership.
@@ -27,6 +27,7 @@ import {
27
27
  validatePiSessionJsonlDirectory,
28
28
  validatePiSessionJsonlFile,
29
29
  } from './session-jsonl.js';
30
+ import { resolvePiChamberDataDir } from '../pichamber-data-dir.js';
30
31
 
31
32
  const PROTOCOL_VERSION = 1;
32
33
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
@@ -138,6 +139,7 @@ export function createSessionDaemon({
138
139
  const toolStartedAt = new Map();
139
140
  const latestUserMessageIds = new Map();
140
141
  const retryStateBySession = new Map();
142
+ const activeRunStartedAt = new Map();
141
143
  const sendGenerationBySession = new Map();
142
144
  const streamingRedactionBuffers = new Map();
143
145
  const loginAttempts = new Map();
@@ -266,6 +268,8 @@ export function createSessionDaemon({
266
268
  ...(activeSession?.thinkingLevel ? { thinking: activeSession.thinkingLevel } : {}),
267
269
  ...(typeof lastAssistant?.text === 'string' ? { lastText: lastAssistant.text } : {}),
268
270
  ...(typeof lastAssistant?.thinking === 'string' ? { lastThinking: lastAssistant.thinking } : {}),
271
+ ...(session.sessionId && activeRunStartedAt.has(session.sessionId) ? { runStartedAt: activeRunStartedAt.get(session.sessionId) } : {}),
272
+ serverNow: Date.now(),
269
273
  lastSequence: snapshotSequence,
270
274
  },
271
275
  });
@@ -746,10 +750,16 @@ export function createSessionDaemon({
746
750
  isStreaming,
747
751
  lifecycle: retry ? 'retry' : isStreaming ? 'busy' : 'idle',
748
752
  ...(retry ? { retry } : {}),
753
+ ...(activeRunStartedAt.has(session.sessionId) ? { runStartedAt: activeRunStartedAt.get(session.sessionId) } : {}),
754
+ serverNow: Date.now(),
749
755
  };
750
756
  };
751
757
 
752
758
  const createSession = async (payload) => {
759
+ const explicitRetryLimit = payload && typeof payload === 'object' ? (payload.maxRetries ?? payload.retryLimit ?? payload.defaultRetryLimit) : undefined;
760
+ if (explicitRetryLimit !== undefined && (!Number.isInteger(explicitRetryLimit) || explicitRetryLimit < 0 || explicitRetryLimit > 10)) {
761
+ throw new SessionDaemonProtocolError('INVALID_ARGUMENT', 'The retry limit must be an integer between 0 and 10.');
762
+ }
753
763
  if (!payload || typeof payload !== 'object'
754
764
  || (payload.cwd !== undefined && (typeof payload.cwd !== 'string' || payload.cwd.length === 0))
755
765
  || (payload.title !== undefined && (typeof payload.title !== 'string' || payload.title.trim().length === 0 || payload.title.length > 256))
@@ -771,6 +781,32 @@ export function createSessionDaemon({
771
781
  ...(parent ? { sessionFile: parent.path } : {}),
772
782
  });
773
783
  if (!newRuntime.cwd) newRuntime.cwd = targetCwd;
784
+ // Apply default retry limit for new sessions. Explicit per-run overrides win.
785
+ try {
786
+ const settingsManager = newRuntime.services?.settingsManager;
787
+ if (settingsManager && typeof settingsManager.getRetrySettings === 'function') {
788
+ let effective = explicitRetryLimit;
789
+ if (effective === undefined) {
790
+ try {
791
+ const raw = await readFile(join(resolvePiChamberDataDir(), 'pi', 'settings.json'), 'utf8');
792
+ const parsed = JSON.parse(raw);
793
+ if (Number.isInteger(parsed?.defaultRetryLimit)) effective = parsed.defaultRetryLimit;
794
+ } catch {}
795
+ if (effective === undefined) effective = 3;
796
+ }
797
+ if (Number.isInteger(effective) && effective >= 0 && effective <= 10) {
798
+ const current = settingsManager.getRetrySettings().maxRetries;
799
+ if (current !== effective) {
800
+ if (typeof settingsManager.applyOverrides === 'function') {
801
+ settingsManager.applyOverrides({ retry: { maxRetries: effective } });
802
+ if (explicitRetryLimit === undefined) await settingsManager.flush().catch(() => {});
803
+ } else if (settingsManager.globalSettings) {
804
+ settingsManager.globalSettings.retry = { ...(settingsManager.globalSettings.retry ?? {}), maxRetries: effective };
805
+ }
806
+ }
807
+ }
808
+ }
809
+ } catch {}
774
810
  let result = { cancelled: false };
775
811
  if (typeof newRuntime.newSession === 'function') {
776
812
  result = await newRuntime.newSession({
@@ -1356,11 +1392,72 @@ export function createSessionDaemon({
1356
1392
  }
1357
1393
  };
1358
1394
 
1359
- const redactAttachmentPaths = (text) => typeof text === 'string'
1360
- ? text
1361
- .replace(/\[Attachment[^\]\r\n]*pi-clipboard-[0-9a-f-]{36}[^\]\r\n]*\]/gi, '[attachment]')
1362
- .replace(/(?:[A-Za-z]:)?[^\s[\](){}"'`]*pi-clipboard-[0-9a-f-]{36}(?:\.[^\s\])}\]"'`,;]+)?/gi, '[attachment]')
1363
- : '';
1395
+ const attachmentMarkerPattern = /pi-clipboard-/i;
1396
+ const attachmentMarkerSearchPattern = /pi-clipboard-/gi;
1397
+ const attachmentIdPattern = /^[0-9a-f-]{36}$/i;
1398
+ const attachmentBracketStartPattern = /\[Attachment/gi;
1399
+ const attachmentTokenPattern = /pi-clipboard-[0-9a-f-]{36}/i;
1400
+ const isAttachmentPathDelimiter = (character) => /[\s[\](){}"'`,;]/u.test(character);
1401
+
1402
+ const redactAttachmentBrackets = (text) => {
1403
+ let cursor = 0;
1404
+ let output = '';
1405
+ while (cursor < text.length) {
1406
+ attachmentBracketStartPattern.lastIndex = cursor;
1407
+ const bracketStart = attachmentBracketStartPattern.exec(text);
1408
+ if (!bracketStart) {
1409
+ output += text.slice(cursor);
1410
+ break;
1411
+ }
1412
+
1413
+ output += text.slice(cursor, bracketStart.index);
1414
+ let bracketEnd = bracketStart.index + bracketStart[0].length;
1415
+ while (bracketEnd < text.length && text[bracketEnd] !== ']' && text[bracketEnd] !== '\r' && text[bracketEnd] !== '\n') bracketEnd += 1;
1416
+ if (text[bracketEnd] === ']') {
1417
+ const candidate = text.slice(bracketStart.index, bracketEnd + 1);
1418
+ output += attachmentTokenPattern.test(candidate) ? '[attachment]' : candidate;
1419
+ cursor = bracketEnd + 1;
1420
+ } else {
1421
+ output += text.slice(bracketStart.index, bracketEnd);
1422
+ cursor = bracketEnd;
1423
+ }
1424
+ }
1425
+ return output;
1426
+ };
1427
+
1428
+ const redactAttachmentPaths = (text) => {
1429
+ if (typeof text !== 'string') return '';
1430
+
1431
+ if (!attachmentMarkerPattern.test(text)) return text;
1432
+ const bracketRedacted = redactAttachmentBrackets(text);
1433
+ let cursor = 0;
1434
+ let output = '';
1435
+ while (cursor < bracketRedacted.length) {
1436
+ attachmentMarkerSearchPattern.lastIndex = cursor;
1437
+ const markerMatch = attachmentMarkerSearchPattern.exec(bracketRedacted);
1438
+ if (!markerMatch) {
1439
+ output += bracketRedacted.slice(cursor);
1440
+ break;
1441
+ }
1442
+
1443
+ const markerIndex = markerMatch.index;
1444
+ const idStart = markerIndex + markerMatch[0].length;
1445
+ const idEnd = idStart + 36;
1446
+ if (!attachmentIdPattern.test(bracketRedacted.slice(idStart, idEnd))) {
1447
+ output += bracketRedacted.slice(cursor, idStart);
1448
+ cursor = idStart;
1449
+ continue;
1450
+ }
1451
+
1452
+ let tokenStart = markerIndex;
1453
+ while (tokenStart > cursor && !isAttachmentPathDelimiter(bracketRedacted[tokenStart - 1])) tokenStart -= 1;
1454
+ let tokenEnd = idEnd;
1455
+ while (tokenEnd < bracketRedacted.length && !isAttachmentPathDelimiter(bracketRedacted[tokenEnd])) tokenEnd += 1;
1456
+ output += `${bracketRedacted.slice(cursor, tokenStart)}[attachment]`;
1457
+ cursor = tokenEnd;
1458
+ }
1459
+ return output;
1460
+ };
1364
1461
 
1365
1462
  const redactAttachmentValues = (value) => {
1366
1463
  if (typeof value === 'string') return redactAttachmentPaths(value);
@@ -1611,9 +1708,10 @@ export function createSessionDaemon({
1611
1708
  }
1612
1709
  messageEntryAliases.clearSession({ cwd: active?.cwd || targetDir, sessionId });
1613
1710
  retryStateBySession.delete(sessionId);
1711
+ activeRunStartedAt.delete(sessionId);
1614
1712
  latestUserMessageIds.delete(sessionId);
1615
1713
  latestAssistantMessageIds.delete(sessionId);
1616
- publish('session.lifecycle', { state: 'idle', deleted: true }, sessionId, targetDir);
1714
+ publish('session.lifecycle', { state: 'idle', deleted: true, serverNow: Date.now() }, sessionId, targetDir);
1617
1715
  };
1618
1716
 
1619
1717
  const publishSessionEvent = (sessionId, event, directory = activeDirectory || cwd) => {
@@ -1756,7 +1854,8 @@ export function createSessionDaemon({
1756
1854
  case 'agent_start':
1757
1855
  clearIdleDisposal(sessionId);
1758
1856
  retryStateBySession.delete(sessionId);
1759
- publish('session.lifecycle', { state: 'busy' }, sessionId, directory);
1857
+ if (!activeRunStartedAt.has(sessionId)) activeRunStartedAt.set(sessionId, Date.now());
1858
+ publish('session.lifecycle', { state: 'busy', runStartedAt: activeRunStartedAt.get(sessionId), serverNow: Date.now() }, sessionId, directory);
1760
1859
  break;
1761
1860
  case 'agent_end': {
1762
1861
  const finalMessage = event.messages?.at?.(-1);
@@ -1774,7 +1873,8 @@ export function createSessionDaemon({
1774
1873
  message: redactAttachmentPaths(event.errorMessage),
1775
1874
  };
1776
1875
  retryStateBySession.set(sessionId, retry);
1777
- publish('session.lifecycle', { state: 'retry', ...retry }, sessionId, directory);
1876
+ if (!activeRunStartedAt.has(sessionId)) activeRunStartedAt.set(sessionId, Date.now());
1877
+ publish('session.lifecycle', { state: 'retry', ...retry, runStartedAt: activeRunStartedAt.get(sessionId), serverNow: Date.now() }, sessionId, directory);
1778
1878
  break;
1779
1879
  }
1780
1880
  case 'auto_retry_end':
@@ -1782,9 +1882,10 @@ export function createSessionDaemon({
1782
1882
  break;
1783
1883
  case 'agent_settled':
1784
1884
  retryStateBySession.delete(sessionId);
1885
+ activeRunStartedAt.delete(sessionId);
1785
1886
  latestUserMessageIds.delete(sessionId);
1786
1887
  latestAssistantMessageIds.delete(sessionId);
1787
- publish('session.lifecycle', { state: 'idle' }, sessionId, directory);
1888
+ publish('session.lifecycle', { state: 'idle', serverNow: Date.now() }, sessionId, directory);
1788
1889
  scheduleIdleDisposal(sessionId);
1789
1890
  break;
1790
1891
  case 'thinking_level_changed':
@@ -2174,6 +2275,7 @@ export function createSessionDaemon({
2174
2275
  }
2175
2276
  loginAttempts.clear();
2176
2277
  retryStateBySession.clear();
2278
+ activeRunStartedAt.clear();
2177
2279
  latestUserMessageIds.clear();
2178
2280
  latestAssistantMessageIds.clear();
2179
2281
  for (const client of clients) client.destroy();
@@ -1016,6 +1016,81 @@ describe('Pi session daemon spike', () => {
1016
1016
  await client.close();
1017
1017
  });
1018
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
+
1019
1094
  it('keeps Pi global/project defaults and trust decisions authoritative', async () => {
1020
1095
  const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-settings-'));
1021
1096
  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
  });
@@ -479,6 +479,38 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
479
479
  }
480
480
  };
481
481
 
482
+ const runWithPairingCreateAuth = async (req, res, next, handler) => {
483
+ try {
484
+ if (typeof uiAuthController.resolveAuthContext === 'function') {
485
+ const context = await uiAuthController.resolveAuthContext(req, res, {
486
+ allowClientAuth: true,
487
+ allowUrlToken: false,
488
+ });
489
+ if (context?.type === 'session') {
490
+ await handler(context);
491
+ return;
492
+ }
493
+ if (context?.type === 'client') {
494
+ const client = await clientRecordFromAuthContext(context);
495
+ // Trusted desktop clients (local or remote) may create short-lived
496
+ // pairing tickets scoped to this server/user. Mobile or other
497
+ // client kinds remain prohibited from creating pairing sessions.
498
+ if (client?.clientKind === 'desktop-local' || client?.clientKind === 'desktop') {
499
+ await handler({ ...context, client });
500
+ return;
501
+ }
502
+ return res.status(403).json({ error: 'Client tokens cannot create pairing sessions' });
503
+ }
504
+ }
505
+
506
+ await runWithUiAuth(req, res, next, async () => {
507
+ await handler({ type: 'session' });
508
+ }, { sessionOnly: true });
509
+ } catch (error) {
510
+ next(error);
511
+ }
512
+ };
513
+
482
514
  const clientIdFromAuthContext = (context) => {
483
515
  const raw = context?.client?.id || context?.clientId;
484
516
  return typeof raw === 'string' && raw.length > 0 ? raw : null;
@@ -811,7 +843,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
811
843
  });
812
844
 
813
845
  app.post('/api/client-auth/pairing/sessions', express.json({ limit: '64kb' }), async (req, res, next) => {
814
- await runWithClientCreateAuth(req, res, next, async (authContext) => {
846
+ await runWithPairingCreateAuth(req, res, next, async (authContext) => {
815
847
  const candidates = await pairingServerCandidates(req, {
816
848
  preferredServerUrl: req.body?.serverUrl,
817
849
  includeRelay: typeof req.body?.includeRelay === 'boolean' ? req.body.includeRelay : undefined,
@@ -877,7 +909,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
877
909
 
878
910
  // Direct transports the server can be reached on (for the create-device dialog).
879
911
  app.get('/api/client-auth/pairing/transports', async (req, res, next) => {
880
- await runWithClientCreateAuth(req, res, next, async () => {
912
+ await runWithPairingCreateAuth(req, res, next, async () => {
881
913
  res.setHeader('Cache-Control', 'no-store');
882
914
  res.json(getPairingTransports(req));
883
915
  });
@@ -886,7 +918,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
886
918
  // Pending pairing sessions (link created, device not yet connected) for the
887
919
  // "pending devices" list. Secrets are never included.
888
920
  app.get('/api/client-auth/pairing/sessions', async (req, res, next) => {
889
- await runWithClientCreateAuth(req, res, next, async () => {
921
+ await runWithPairingCreateAuth(req, res, next, async () => {
890
922
  const pending = await clientPairingRuntime.listPendingSessions();
891
923
  res.setHeader('Cache-Control', 'no-store');
892
924
  res.json({ pending });
@@ -894,7 +926,7 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
894
926
  });
895
927
 
896
928
  app.delete('/api/client-auth/pairing/sessions/:id', async (req, res, next) => {
897
- await runWithClientCreateAuth(req, res, next, async () => {
929
+ await runWithPairingCreateAuth(req, res, next, async () => {
898
930
  const result = await clientPairingRuntime.cancelPairingSession(req.params?.id);
899
931
  if (!result.cancelled) {
900
932
  return res.status(404).json({ cancelled: false, error: 'Pairing session not found' });