@pi-chamber/web 0.3.1 → 0.3.3
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.
- package/bin/lib/cli-startup.js +53 -10
- package/bin/lib/cli-startup.test.js +42 -0
- package/dist/assets/{CommitSection-C336377Z.js → CommitSection-BAYzLtBf.js} +1 -1
- package/dist/assets/{DiagramEditor-gHLCXHtr.js → DiagramEditor-D1yc4joK.js} +1 -1
- package/dist/assets/{DiagramView-DlHzRRBc.js → DiagramView-CAlJiG11.js} +1 -1
- package/dist/assets/{DiffView-5wHwK-lg.js → DiffView-DGD9DMS7.js} +2 -2
- package/dist/assets/{FilesView-5bswI3JZ.js → FilesView-DB5VI7EN.js} +6 -6
- package/dist/assets/{GitView-BfykGpGh.js → GitView-CFlPcSn9.js} +4 -4
- package/dist/assets/{JsonTreeView-HVwQS7qG.js → JsonTreeView-CoX1ZscV.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-kjd4e0N8.js → MarkdownRendererImpl-V1HMFCjr.js} +1 -1
- package/dist/assets/{PatchDiff-Dlm4RU8n.js → PatchDiff-D6Gs7K5B.js} +1 -1
- package/dist/assets/{PierreDiffViewer-St32rXlD.js → PierreDiffViewer-COYd5Qr-.js} +2 -2
- package/dist/assets/{SessionSwitcherDropdown-D5bWfZNA.js → SessionSwitcherDropdown-o1RjhCfF.js} +93 -93
- package/dist/assets/SettingsView-BYqvfMTS.js +4 -0
- package/dist/assets/{SettingsWindow-Ck_uhTnx.js → SettingsWindow-CtNS0tQn.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BCONUv3r.js → ToolOutputDialog-2zKUdGYL.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-BdVuk2kc.js → ToolPartDiffPreview-CBFC9min.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-BsAZM0jo.js → VirtualizedFileDiff-UMgLcO4Q.js} +4 -4
- package/dist/assets/{collapsible-Wnmq-13H.js → collapsible-Kx4bmsQM.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-skbyL2Az.js → computeVirtualFileMetrics-DdOCbMyK.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-BsxQITMZ.js → contextFileOpenGuard-BRldMp0d.js} +1 -1
- package/dist/assets/{index-8TL2HlaR.js → index-BAqpUCjs.js} +4 -4
- package/dist/assets/{index-Bfp6eYap.js → index-CuSNEV7s.js} +1 -1
- package/dist/assets/{isDiffPlainText-dZkfk3yG.js → isDiffPlainText-CGIf0q8p.js} +1 -1
- package/dist/assets/{isFilePlainText-UL_tMQZx.js → isFilePlainText-D4hgTRxT.js} +1 -1
- package/dist/assets/{main-CHQfoyRP.js → main-By_pvk90.js} +2 -2
- package/dist/assets/main-CmkRaMOR.js +133 -0
- package/dist/assets/{markdownCore-qX3mJFkL.js → markdownCore-CAVCyFnI.js} +2 -2
- package/dist/assets/{miniChat-3QtCNwHl.js → miniChat-_XK2gGZE.js} +2 -2
- package/dist/assets/{mobile-B_CyfRAX.js → mobile-B4E4JqAf.js} +2 -2
- package/dist/assets/{renderElectronMiniChatApp-OToslrv-.js → renderElectronMiniChatApp-Ca4hZgIz.js} +1 -1
- package/dist/assets/{renderMobileApp-BD_wep_I.js → renderMobileApp-ClA5Cnmw.js} +4 -4
- package/dist/assets/{runtimeSurface-CkdOpqo7.js → runtimeSurface-CkHYYg0E.js} +1 -1
- package/dist/assets/{useInlineCommentController-DVif5Fr8.js → useInlineCommentController-DD31jQNC.js} +2 -2
- package/dist/assets/usePanelSlide-a4c67FIY.js +56 -0
- package/dist/assets/{useWindowTitle-BZVlLMCy.js → useWindowTitle-DoHrUC5r.js} +3 -3
- package/dist/assets/{vendor-codemirror-language-data-C-924Z4o.js → vendor-codemirror-language-data-Dp_Z-0GD.js} +2 -2
- package/dist/assets/{web-components-BG-6PEN4.js → web-components-Cct1r-wn.js} +4 -4
- package/dist/index.html +3 -3
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/index.js +33 -0
- package/server/lib/pi/routes.js +5 -1
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +1 -1
- package/server/lib/pi/session-daemon/session-daemon.js +111 -9
- package/server/lib/pi/session-daemon/session-daemon.test.js +75 -0
- package/server/lib/pi/settings-store.js +17 -0
- package/server/lib/pi/settings-store.test.js +33 -0
- package/server/lib/server/core-routes.js +36 -4
- package/server/lib/server/tunnel-service.DOCUMENTATION.md +74 -0
- package/server/lib/server/tunnel-service.js +228 -0
- package/server/lib/server/tunnel-service.test.js +61 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +2 -0
- package/server/lib/ui-auth/ui-auth.js +5 -1
- package/server/lib/ui-auth/ui-auth.test.js +17 -0
- package/dist/assets/SettingsView-FGe5TEQq.js +0 -4
- package/dist/assets/main-DbnYXZeK.js +0 -133
- 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-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-B4E4JqAf.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-
|
|
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
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
|
|
package/server/lib/pi/routes.js
CHANGED
|
@@ -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
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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' });
|