@pi-chamber/web 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.test.js +6 -8
- package/bin/lib/cli-tunnel-capabilities.js +0 -2
- package/dist/assets/{CommitSection-DPjTv8fA.js → CommitSection-C336377Z.js} +1 -1
- package/dist/assets/{DiagramEditor-ChOor9l9.js → DiagramEditor-gHLCXHtr.js} +1 -1
- package/dist/assets/{DiagramView-3hAT86v_.js → DiagramView-DlHzRRBc.js} +1 -1
- package/dist/assets/{DiffView-D1dyOiw2.js → DiffView-5wHwK-lg.js} +1 -1
- package/dist/assets/{FilesView-BCzghrc7.js → FilesView-5bswI3JZ.js} +6 -6
- package/dist/assets/{GitView-CYF1E1p4.js → GitView-BfykGpGh.js} +4 -4
- package/dist/assets/{JsonTreeView-CrJO6LNu.js → JsonTreeView-HVwQS7qG.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-BmKjrkGD.js → MarkdownRendererImpl-kjd4e0N8.js} +1 -1
- package/dist/assets/{PatchDiff-DgvPLx6x.js → PatchDiff-Dlm4RU8n.js} +1 -1
- package/dist/assets/{PierreDiffViewer-InnT0JVA.js → PierreDiffViewer-St32rXlD.js} +1 -1
- package/dist/assets/{SessionSwitcherDropdown-C0OdvirK.js → SessionSwitcherDropdown-D5bWfZNA.js} +100 -101
- package/dist/assets/SettingsView-FGe5TEQq.js +4 -0
- package/dist/assets/SettingsWindow-Ck_uhTnx.js +1 -0
- package/dist/assets/{ToolOutputDialog-gzwcb8nI.js → ToolOutputDialog-BCONUv3r.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-C45V5bhy.js → ToolPartDiffPreview-BdVuk2kc.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-uKfnkQWb.js → VirtualizedFileDiff-BsAZM0jo.js} +1 -1
- package/dist/assets/{collapsible-Bq8sDiKS.js → collapsible-Wnmq-13H.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BPgZ_RSe.js → computeVirtualFileMetrics-skbyL2Az.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-CydO7PkI.js → contextFileOpenGuard-BsxQITMZ.js} +1 -1
- package/dist/assets/index-8TL2HlaR.js +8 -0
- package/dist/assets/{index-BSuNNtBh.js → index-Bfp6eYap.js} +1 -1
- package/dist/assets/index-riABQmQ_.css +1 -0
- package/dist/assets/{isDiffPlainText-f9b4cKNo.js → isDiffPlainText-dZkfk3yG.js} +1 -1
- package/dist/assets/{isFilePlainText-BjESb-gU.js → isFilePlainText-UL_tMQZx.js} +1 -1
- package/dist/assets/{main-B508FBXr.js → main-CHQfoyRP.js} +2 -2
- package/dist/assets/main-DbnYXZeK.js +133 -0
- package/dist/assets/{markdownCore-BFmf8a-O.js → markdownCore-qX3mJFkL.js} +2 -2
- package/dist/assets/{miniChat-BTlz8IPL.js → miniChat-3QtCNwHl.js} +2 -2
- package/dist/assets/{mobile-DBkrRt80.js → mobile-B_CyfRAX.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-OToslrv-.js +1 -0
- package/dist/assets/renderMobileApp-BD_wep_I.js +14 -0
- package/dist/assets/{runtimeSurface-Bec4O8FD.js → runtimeSurface-CkdOpqo7.js} +1 -1
- package/dist/assets/{useInlineCommentController-CHmIm0Rr.js → useInlineCommentController-DVif5Fr8.js} +2 -2
- package/dist/assets/usePanelSlide-C_PJmPXW.js +56 -0
- package/dist/assets/{useWindowTitle-CE5N_NeU.js → useWindowTitle-BZVlLMCy.js} +3 -3
- package/dist/assets/{vendor-base-ui-DwfTg5wD.js → vendor-base-ui-DL8r2fgQ.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-q1yHU1R9.js → vendor-codemirror-language-data-C-924Z4o.js} +2 -2
- package/dist/assets/{vendor-sonner-dWA7qvjr.js → vendor-sonner-CW8w438f.js} +1 -1
- package/dist/assets/{web-components-CLirSfwt.js → web-components-BG-6PEN4.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +3 -3
- package/dist/mobile.html +3 -3
- package/package.json +1 -1
- package/server/lib/package-manager.js +0 -34
- package/server/lib/pi/routes.js +52 -4
- package/server/lib/pi/routes.test.js +14 -6
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +4 -3
- package/server/lib/pi/session-daemon/message-entry-aliases.js +100 -0
- package/server/lib/pi/session-daemon/runtime-registry.js +3 -0
- package/server/lib/pi/session-daemon/session-daemon.js +184 -16
- package/server/lib/pi/session-daemon/session-daemon.test.js +310 -5
- package/server/lib/pi/session-daemon/supervisor.js +20 -1
- package/server/lib/pi/session-daemon/supervisor.test.js +43 -0
- package/server/lib/tunnels/DOCUMENTATION.md +6 -7
- package/server/lib/tunnels/executable-search.test.js +4 -4
- package/server/lib/tunnels/install-help.js +0 -10
- package/server/lib/tunnels/install-help.test.js +0 -9
- package/server/lib/tunnels/types.js +0 -1
- package/dist/assets/SettingsView-JAfC_cBL.js +0 -3
- package/dist/assets/SettingsWindow-BBEsjcJE.js +0 -1
- package/dist/assets/index-CH7o4-_q.js +0 -8
- package/dist/assets/index-o4Eea60G.css +0 -1
- package/dist/assets/main-nG4uFyAy.js +0 -133
- package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +0 -1
- package/dist/assets/renderMobileApp-9qkroKty.js +0 -14
- package/dist/assets/usePanelSlide-DCX8qQ4K.js +0 -56
- package/server/lib/ngrok-tunnel.js +0 -343
- package/server/lib/ngrok-tunnel.test.js +0 -50
- package/server/lib/tunnels/providers/ngrok.js +0 -32
package/dist/mobile.html
CHANGED
|
@@ -63,14 +63,14 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-B_CyfRAX.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-8TL2HlaR.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="/assets/vendor-katex-CHD9BdDO.js">
|
|
73
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
73
|
+
<link rel="stylesheet" crossorigin href="/assets/index-riABQmQ_.css">
|
|
74
74
|
<link rel="stylesheet" crossorigin href="/assets/vendor-katex-Cp579UoJ.css">
|
|
75
75
|
</head>
|
|
76
76
|
<body class="h-full bg-background text-foreground">
|
package/package.json
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process';
|
|
2
|
-
import crypto from 'crypto';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import path from 'path';
|
|
5
4
|
import { fileURLToPath } from 'url';
|
|
6
5
|
|
|
7
|
-
import { resolvePiChamberDataDir, resolvePiChamberDataPath } from './pichamber-data-dir.js';
|
|
8
|
-
|
|
9
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
7
|
const __dirname = path.dirname(__filename);
|
|
11
8
|
|
|
@@ -40,34 +37,6 @@ function getConfiguredUpdateCheckUrl() {
|
|
|
40
37
|
return override.length > 0 ? override : null;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
function getPiChamberConfigDir() {
|
|
44
|
-
// Use the canonical resolver so PICHAMBER_DATA_DIR is honored everywhere.
|
|
45
|
-
return resolvePiChamberDataDir();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function sanitizeInstallScope(scope) {
|
|
49
|
-
if (scope === 'desktop-electron' || scope === 'web' || scope === 'mobile-capacitor') return scope;
|
|
50
|
-
return 'web';
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getOrCreateInstallId(scope = 'web') {
|
|
54
|
-
const configDir = getPiChamberConfigDir();
|
|
55
|
-
const normalizedScope = sanitizeInstallScope(scope);
|
|
56
|
-
const idPath = path.join(configDir, `install-id-${normalizedScope}`);
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const existing = fs.readFileSync(idPath, 'utf8').trim();
|
|
60
|
-
if (existing) return existing;
|
|
61
|
-
} catch {
|
|
62
|
-
// Generate new id.
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const installId = crypto.randomUUID();
|
|
66
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
67
|
-
fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
68
|
-
return installId;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
40
|
function mapPlatform(value) {
|
|
72
41
|
if (value === 'darwin') return 'macos';
|
|
73
42
|
if (value === 'win32') return 'windows';
|
|
@@ -140,7 +109,6 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
|
|
|
140
109
|
const shouldTrustClientPlatform = appType === 'desktop-electron' || appType === 'mobile-capacitor';
|
|
141
110
|
const platform = shouldTrustClientPlatform ? normalizePlatform(options.platform) : hostPlatform;
|
|
142
111
|
const arch = shouldTrustClientPlatform ? normalizeArch(options.arch) : hostArch;
|
|
143
|
-
const reportUsage = options.reportUsage !== false;
|
|
144
112
|
const payload = {
|
|
145
113
|
appType,
|
|
146
114
|
deviceClass: normalizeDeviceClass(options.deviceClass),
|
|
@@ -148,9 +116,7 @@ async function checkForUpdatesFromApi(currentVersion, options = {}) {
|
|
|
148
116
|
arch,
|
|
149
117
|
channel: 'stable',
|
|
150
118
|
currentVersion,
|
|
151
|
-
installId: reportUsage ? (options.installId || getOrCreateInstallId(appType)) : undefined,
|
|
152
119
|
instanceMode: options.instanceMode || 'unknown',
|
|
153
|
-
reportUsage,
|
|
154
120
|
};
|
|
155
121
|
|
|
156
122
|
const response = await fetch(updateCheckUrl, {
|
package/server/lib/pi/routes.js
CHANGED
|
@@ -135,6 +135,32 @@ const projectUsage = (raw) => {
|
|
|
135
135
|
};
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
const projectRetryInfo = (value) => {
|
|
139
|
+
if (!value || typeof value !== 'object') return null;
|
|
140
|
+
return {
|
|
141
|
+
...(Number.isSafeInteger(value.attempt) && value.attempt > 0 ? { attempt: value.attempt } : {}),
|
|
142
|
+
...(Number.isFinite(value.next) && value.next >= 0 ? { next: value.next } : {}),
|
|
143
|
+
...(typeof value.message === 'string' ? { message: value.message } : {}),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const sanitizeNavigation = (value) => {
|
|
148
|
+
if (!value || typeof value !== 'object' || typeof value.targetEntryId !== 'string' || value.targetEntryId.length === 0) return null;
|
|
149
|
+
const previousLeafId = value.previousLeafId === null ? null : (typeof value.previousLeafId === 'string' ? value.previousLeafId : null);
|
|
150
|
+
const newLeafId = value.newLeafId === null ? null : (typeof value.newLeafId === 'string' ? value.newLeafId : null);
|
|
151
|
+
if (previousLeafId !== null && typeof previousLeafId !== 'string') return null;
|
|
152
|
+
if (newLeafId !== null && typeof newLeafId !== 'string') return null;
|
|
153
|
+
const editorText = typeof value.editorText === 'string' ? value.editorText : undefined;
|
|
154
|
+
// Limit editorText defensively; Pi prompts are bounded and we never persist this.
|
|
155
|
+
if (editorText !== undefined && editorText.length > 200_000) return null;
|
|
156
|
+
return {
|
|
157
|
+
targetEntryId: value.targetEntryId,
|
|
158
|
+
previousLeafId,
|
|
159
|
+
newLeafId,
|
|
160
|
+
...(editorText !== undefined ? { editorText } : {}),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
138
164
|
const projectSessionDetail = (value) => {
|
|
139
165
|
if (!value || typeof value !== 'object' || !Array.isArray(value.messages) || !Number.isSafeInteger(value.lastSequence)) throw protocolMismatch();
|
|
140
166
|
const messages = value.messages.map((item) => {
|
|
@@ -185,12 +211,14 @@ const projectSessionDetail = (value) => {
|
|
|
185
211
|
const lifecycle = ['idle', 'busy', 'retry', 'error', 'interrupted'].includes(value.lifecycle)
|
|
186
212
|
? value.lifecycle
|
|
187
213
|
: (isStreaming ? 'busy' : 'idle');
|
|
214
|
+
const retry = lifecycle === 'retry' ? projectRetryInfo(value.retry) : null;
|
|
188
215
|
return {
|
|
189
216
|
session: projectSession(value.session),
|
|
190
217
|
messages,
|
|
191
218
|
lastSequence: value.lastSequence,
|
|
192
219
|
isStreaming,
|
|
193
220
|
lifecycle,
|
|
221
|
+
...(retry ? { retry } : {}),
|
|
194
222
|
};
|
|
195
223
|
};
|
|
196
224
|
|
|
@@ -341,9 +369,12 @@ const projectEventFrame = (frame) => {
|
|
|
341
369
|
switch (frame.event) {
|
|
342
370
|
case 'session.snapshot': {
|
|
343
371
|
const snapshot = frame.payload;
|
|
372
|
+
const lifecycle = ['idle', 'busy', 'retry', 'error', 'interrupted'].includes(snapshot.lifecycle) ? snapshot.lifecycle : 'idle';
|
|
373
|
+
const retry = lifecycle === 'retry' ? projectRetryInfo(snapshot.retry) : null;
|
|
344
374
|
return { ...common, payload: { snapshot: {
|
|
345
375
|
sessionId, directory, isStreaming: snapshot.isStreaming === true,
|
|
346
|
-
lifecycle
|
|
376
|
+
lifecycle,
|
|
377
|
+
...(retry ? { retry } : {}),
|
|
347
378
|
queue: { steering: Number.isSafeInteger(snapshot.queue?.steering) ? snapshot.queue.steering : 0, followUp: Number.isSafeInteger(snapshot.queue?.followUp) ? snapshot.queue.followUp : 0 },
|
|
348
379
|
...(snapshot.model && typeof snapshot.model.providerId === 'string' && typeof snapshot.model.modelId === 'string' ? { model: { providerId: snapshot.model.providerId, modelId: snapshot.model.modelId } } : {}),
|
|
349
380
|
...(typeof snapshot.thinking === 'string' ? { thinking: snapshot.thinking } : {}),
|
|
@@ -352,7 +383,10 @@ const projectEventFrame = (frame) => {
|
|
|
352
383
|
lastSequence: Number.isSafeInteger(snapshot.lastSequence) ? snapshot.lastSequence : frame.sequence,
|
|
353
384
|
} } };
|
|
354
385
|
}
|
|
355
|
-
case 'session.lifecycle':
|
|
386
|
+
case 'session.lifecycle': {
|
|
387
|
+
const retry = frame.payload.state === 'retry' ? projectRetryInfo(frame.payload) : null;
|
|
388
|
+
return { ...common, payload: { state: frame.payload.state, ...(retry ?? {}) } };
|
|
389
|
+
}
|
|
356
390
|
case 'session.updated': {
|
|
357
391
|
if (typeof frame.payload.title !== 'string') return null;
|
|
358
392
|
const title = frame.payload.title.trim();
|
|
@@ -369,6 +403,7 @@ const projectEventFrame = (frame) => {
|
|
|
369
403
|
...(typeof frame.payload.text === 'string' ? { text: frame.payload.text } : {}),
|
|
370
404
|
...(typeof frame.payload.thinking === 'string' ? { thinking: frame.payload.thinking } : {}),
|
|
371
405
|
...(Number.isFinite(frame.payload.durationMs) ? { durationMs: frame.payload.durationMs } : {}),
|
|
406
|
+
...(frame.payload.continuing === true ? { continuing: true } : {}),
|
|
372
407
|
...(frame.payload.error && typeof frame.payload.error.code === 'string'
|
|
373
408
|
? {
|
|
374
409
|
error: {
|
|
@@ -495,7 +530,6 @@ export const registerPiRuntimeRoutes = (app, {
|
|
|
495
530
|
currentVersion: typeof req.query.currentVersion === 'string' ? req.query.currentVersion : undefined,
|
|
496
531
|
appType: typeof req.query.appType === 'string' ? req.query.appType : undefined,
|
|
497
532
|
platform: typeof req.query.platform === 'string' ? req.query.platform : undefined,
|
|
498
|
-
reportUsage: req.query.reportUsage === 'true',
|
|
499
533
|
}));
|
|
500
534
|
} catch {
|
|
501
535
|
res.status(503).json({ error: 'Update check unavailable' });
|
|
@@ -567,6 +601,16 @@ export const registerPiRuntimeRoutes = (app, {
|
|
|
567
601
|
}
|
|
568
602
|
});
|
|
569
603
|
|
|
604
|
+
app.post('/api/pi/providers/refresh', async (req, res) => {
|
|
605
|
+
const directory = typeof req.body?.directory === 'string' && req.body.directory.length > 0 ? req.body.directory : undefined;
|
|
606
|
+
try {
|
|
607
|
+
const result = await getDaemonRuntime(getPiSessionDaemonRuntime).request('providers.refresh', directory ? { directory } : undefined);
|
|
608
|
+
res.json(projectProviders(result));
|
|
609
|
+
} catch (error) {
|
|
610
|
+
writeDaemonError(res, error);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
570
614
|
app.get('/api/pi/providers/:providerId/config', async (req, res) => {
|
|
571
615
|
const providerId = req.params.providerId;
|
|
572
616
|
if (typeof providerId !== 'string' || providerId.length === 0) {
|
|
@@ -891,7 +935,11 @@ export const registerPiRuntimeRoutes = (app, {
|
|
|
891
935
|
|
|
892
936
|
app.post('/api/pi/sessions/:sessionId/navigate', async (req, res) => {
|
|
893
937
|
const result = await requestSessionOperation(req, res, getPiSessionDaemonRuntime, 'sessions.navigate', { messageId: req.body?.messageId });
|
|
894
|
-
if (result
|
|
938
|
+
if (result === undefined) return;
|
|
939
|
+
const detail = projectSessionDetail(result);
|
|
940
|
+
const navigation = sanitizeNavigation(result.navigation);
|
|
941
|
+
if (navigation) detail.navigation = navigation;
|
|
942
|
+
res.json(detail);
|
|
895
943
|
});
|
|
896
944
|
|
|
897
945
|
for (const [suffix, command] of [['fork', 'sessions.fork'], ['clone', 'sessions.clone']]) {
|
|
@@ -443,6 +443,9 @@ describe('Pi runtime route', () => {
|
|
|
443
443
|
parts: [{ type: 'text', id: 'entry-1:0', index: 0, text: 'hello', privatePath: '/private' }],
|
|
444
444
|
}],
|
|
445
445
|
lastSequence: 9,
|
|
446
|
+
isStreaming: true,
|
|
447
|
+
lifecycle: 'retry',
|
|
448
|
+
retry: { attempt: 2, next: 5_000, message: 'provider request timed out' },
|
|
446
449
|
};
|
|
447
450
|
const runtime = {
|
|
448
451
|
health: async () => ({ state: 'ready', protocolVersion: 1, capabilities: [] }),
|
|
@@ -467,8 +470,9 @@ describe('Pi runtime route', () => {
|
|
|
467
470
|
parts: [{ type: 'text', id: 'entry-1:0', index: 0, text: 'hello' }],
|
|
468
471
|
}],
|
|
469
472
|
lastSequence: 9,
|
|
470
|
-
isStreaming:
|
|
471
|
-
lifecycle: '
|
|
473
|
+
isStreaming: true,
|
|
474
|
+
lifecycle: 'retry',
|
|
475
|
+
retry: { attempt: 2, next: 5_000, message: 'provider request timed out' },
|
|
472
476
|
});
|
|
473
477
|
const promptResponse = await fetch(`http://127.0.0.1:${server.address().port}/api/pi/sessions/pi-session-4/prompt`, {
|
|
474
478
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: 'other', text: 'hello' }),
|
|
@@ -539,10 +543,11 @@ describe('Pi runtime route', () => {
|
|
|
539
543
|
const runtime = {
|
|
540
544
|
health: async () => ({ state: 'ready', protocolVersion: 1, capabilities: [] }),
|
|
541
545
|
subscribe: async ({ onEvent }) => {
|
|
542
|
-
onEvent({ protocolVersion: 1, kind: 'event', event: 'session.snapshot', sequence: 4, payload: { sessionId: 'pi-session-5', directory: '/workspace', isStreaming:
|
|
546
|
+
onEvent({ protocolVersion: 1, kind: 'event', event: 'session.snapshot', sequence: 4, payload: { sessionId: 'pi-session-5', directory: '/workspace', isStreaming: true, lifecycle: 'retry', retry: { attempt: 1, next: 4_000, message: 'provider request timed out' }, queue: { steering: 0, followUp: 0 }, lastSequence: 4, endpoint: '/private/socket' } });
|
|
543
547
|
onEvent({ protocolVersion: 1, kind: 'event', event: 'assistant.message.start', sequence: 5, payload: { sessionId: 'pi-session-5', directory: '/workspace', messageId: 'assistant-1', parentId: 'user-1', role: 'assistant', startedAt: 1 } });
|
|
544
|
-
onEvent({ protocolVersion: 1, kind: 'event', event: 'assistant.message.end', sequence: 6, payload: { sessionId: 'pi-session-5', directory: '/workspace', messageId: 'assistant-1', durationMs: 42, error: { code: 'ASSISTANT_ERROR', message: 'provider request timed out' } } });
|
|
545
|
-
onEvent({ protocolVersion: 1, kind: 'event', event: 'session.
|
|
548
|
+
onEvent({ protocolVersion: 1, kind: 'event', event: 'assistant.message.end', sequence: 6, payload: { sessionId: 'pi-session-5', directory: '/workspace', messageId: 'assistant-1', durationMs: 42, continuing: true, error: { code: 'ASSISTANT_ERROR', message: 'provider request timed out' } } });
|
|
549
|
+
onEvent({ protocolVersion: 1, kind: 'event', event: 'session.lifecycle', sequence: 7, payload: { sessionId: 'pi-session-5', directory: '/workspace', state: 'retry', attempt: 2, next: 8_000, message: 'provider request timed out' } });
|
|
550
|
+
onEvent({ protocolVersion: 1, kind: 'event', event: 'session.error', sequence: 8, payload: { sessionId: 'pi-session-5', directory: '/workspace', code: 'ASSISTANT_ERROR', message: 'provider request timed out' } });
|
|
546
551
|
return () => {};
|
|
547
552
|
},
|
|
548
553
|
};
|
|
@@ -553,7 +558,7 @@ describe('Pi runtime route', () => {
|
|
|
553
558
|
const reader = response.body.getReader();
|
|
554
559
|
const first = await reader.read();
|
|
555
560
|
let text = new TextDecoder().decode(first.value);
|
|
556
|
-
while (!text.includes('"
|
|
561
|
+
while (!text.includes('"state":"retry"')) {
|
|
557
562
|
const next = await reader.read();
|
|
558
563
|
if (next.done) break;
|
|
559
564
|
text += new TextDecoder().decode(next.value);
|
|
@@ -561,8 +566,11 @@ describe('Pi runtime route', () => {
|
|
|
561
566
|
await reader.cancel();
|
|
562
567
|
expect(text).toContain('"name":"session.snapshot"');
|
|
563
568
|
expect(text).toContain('"lastSequence":4');
|
|
569
|
+
expect(text).toContain('"retry":{"attempt":1,"next":4000,"message":"provider request timed out"}');
|
|
564
570
|
expect(text).toContain('"parentId":"user-1"');
|
|
565
571
|
expect(text).toContain('"durationMs":42');
|
|
572
|
+
expect(text).toContain('"continuing":true');
|
|
573
|
+
expect(text).toContain('"state":"retry","attempt":2,"next":8000,"message":"provider request timed out"');
|
|
566
574
|
expect(text).toContain('"message":"provider request timed out"');
|
|
567
575
|
expect(text).not.toContain('/private/socket');
|
|
568
576
|
});
|
|
@@ -25,13 +25,14 @@ 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 remain attached to the latest user turn. Assistant message projections also pass through Pi's per-turn `Usage` (input, output, cacheRead, cacheWrite, totalTokens, and the same five numeric cost fields). The daemon sanitizes the raw payload through `projectUsage` before publication: every numeric must be finite and non-negative, the whole object is omitted when any field is missing or wrong type, and unknown keys are never copied. The `assistant.message.end` event carries the same sanitized usage so the last turn updates without waiting for a full `getSession` refetch. The public route whitelist re-applies the same sanitizer on each projection so a stale or malformed payload from a different daemon version cannot surface as `NaN` or `Infinity` in the reducer.
|
|
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.
|
|
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.
|
|
29
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.
|
|
30
|
-
- Prompt, steer, and follow-up requests acknowledge acceptance immediately after Pi accepts the call; the daemon owns the still-running send promise and publishes any asynchronous rejection as `session.error`. A later prompt on the same session invalidates that rejection so it cannot error the new turn. If the rejected send left Pi streaming, the daemon aborts it. Steer/follow-up against an idle session starts a new prompt instead of `SESSION_NOT_RUNNING`. Agent completion is never held behind the 30-second public/private request budgets.
|
|
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.
|
|
31
32
|
- Registry keys combine the SDK session ID with its cwd. A replacement subscription is rebound to the replacement session so old-session events cannot be published under the new identity. A collision is a visible error; it never replaces or disposes an unrelated runtime.
|
|
32
33
|
- After `agent_settled`, the daemon disposes an idle runtime after five minutes (injectable for tests). Disposal never deletes Pi JSONL; the daemon remembers the selected session file and recreates that runtime on the next private prompt. A new agent start cancels a pending disposal.
|
|
33
34
|
- A startup validation failure writes only a stable failure code to the owner-only non-secret state sidecar. It never records a transcript, session path, or credential. The supervisor surfaces that code as unavailable instead of claiming an empty or idle session.
|
|
34
|
-
- After a forced crash, the next health, request, or subscription probe re-runs supervised startup. Daemon readiness has its own 15-second bounded budget, separate from the shorter lock and shutdown budget, because loading Pi settings and provider/model catalogs can legitimately exceed five seconds. The supervisor removes an existing POSIX socket only when its recorded daemon PID is dead, the endpoint is a socket, and authenticated IPC deterministically refuses or times out. An endpoint that responds, authenticates incorrectly, returns malformed protocol data, or cannot be classified remains `DAEMON_ENDPOINT_UNVERIFIED`.
|
|
35
|
+
- After a forced crash, the next health, request, or subscription probe re-runs supervised startup. Each private health probe uses the supervisor's five-second operation budget; it must not inherit the general 30-second command timeout and hold every browser request until the browser aborts. Daemon readiness has its own 15-second bounded budget, separate from the shorter probe, lock, and shutdown budget, because loading Pi settings and provider/model catalogs can legitimately exceed five seconds. The supervisor removes an existing POSIX socket only when its recorded daemon PID is dead, the endpoint is a socket, and authenticated IPC deterministically refuses or times out. An endpoint that responds, authenticates incorrectly, returns malformed protocol data, or cannot be classified remains `DAEMON_ENDPOINT_UNVERIFIED`.
|
|
35
36
|
- The web server starts warming the supervisor immediately after HTTP listen without awaiting Pi provider/model initialization. A first request arriving during that warmup shares the supervisor's `startPromise`.
|
|
36
37
|
- The daemon owns the runtime after clients disconnect. Disconnecting the web-server client cannot stop active Pi work.
|
|
37
38
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const scopeKey = (cwd, sessionId) => JSON.stringify([cwd, sessionId]);
|
|
2
|
+
|
|
3
|
+
const stripPartSuffix = (messageId) => {
|
|
4
|
+
const suffixIndex = messageId.indexOf(':');
|
|
5
|
+
return suffixIndex === -1 ? messageId : messageId.slice(0, suffixIndex);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const findPersistedMessageEntry = (sessionManager, message) => {
|
|
9
|
+
const entries = sessionManager?.getEntries?.();
|
|
10
|
+
if (!Array.isArray(entries)) return undefined;
|
|
11
|
+
return entries.find((entry) => entry?.type === 'message' && entry.message === message);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Keeps daemon-published live message ids resolvable after Pi persists those
|
|
16
|
+
* messages under its own session-entry ids.
|
|
17
|
+
*/
|
|
18
|
+
export function createMessageEntryAliases({ scheduleMicrotask = queueMicrotask } = {}) {
|
|
19
|
+
const aliasesByScope = new Map();
|
|
20
|
+
const pendingByMessage = new WeakMap();
|
|
21
|
+
|
|
22
|
+
const getScope = (cwd, sessionId, create = false) => {
|
|
23
|
+
const key = scopeKey(cwd, sessionId);
|
|
24
|
+
let scope = aliasesByScope.get(key);
|
|
25
|
+
if (!scope && create) {
|
|
26
|
+
scope = new Map();
|
|
27
|
+
aliasesByScope.set(key, scope);
|
|
28
|
+
}
|
|
29
|
+
return scope;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const retain = ({ cwd, sessionId, syntheticMessageId, message }) => {
|
|
33
|
+
if (typeof cwd !== 'string' || typeof sessionId !== 'string' || typeof syntheticMessageId !== 'string'
|
|
34
|
+
|| !message || typeof message !== 'object') return;
|
|
35
|
+
const alias = { message };
|
|
36
|
+
getScope(cwd, sessionId, true).set(syntheticMessageId, alias);
|
|
37
|
+
pendingByMessage.set(message, { alias });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const releasePersisted = (pending, sessionManager) => {
|
|
41
|
+
const entry = findPersistedMessageEntry(sessionManager, pending.alias.message);
|
|
42
|
+
if (!entry || typeof entry.id !== 'string' || entry.id.length === 0) return undefined;
|
|
43
|
+
pending.alias.entryId = entry.id;
|
|
44
|
+
delete pending.alias.message;
|
|
45
|
+
pendingByMessage.delete(entry.message);
|
|
46
|
+
return entry.id;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
retain,
|
|
51
|
+
|
|
52
|
+
observeMessageEnd({ cwd, sessionId, syntheticMessageId, message, sessionManager }) {
|
|
53
|
+
if (typeof syntheticMessageId !== 'string' || !message || typeof message !== 'object') return;
|
|
54
|
+
const alias = getScope(cwd, sessionId)?.get(syntheticMessageId);
|
|
55
|
+
if (!alias) return;
|
|
56
|
+
if (alias.message && alias.message !== message) pendingByMessage.delete(alias.message);
|
|
57
|
+
alias.message = message;
|
|
58
|
+
const pending = { alias };
|
|
59
|
+
pendingByMessage.set(message, pending);
|
|
60
|
+
scheduleMicrotask(() => {
|
|
61
|
+
releasePersisted(pending, sessionManager);
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
resolve({ cwd, sessionId, requestedId, sessionManager }) {
|
|
66
|
+
if (typeof requestedId !== 'string' || requestedId.length === 0) return requestedId;
|
|
67
|
+
const entryId = stripPartSuffix(requestedId);
|
|
68
|
+
if (sessionManager?.getEntry?.(entryId)) return entryId;
|
|
69
|
+
|
|
70
|
+
const alias = getScope(cwd, sessionId)?.get(entryId);
|
|
71
|
+
if (!alias) return entryId;
|
|
72
|
+
if (typeof alias.entryId === 'string' && alias.entryId.length > 0) return alias.entryId;
|
|
73
|
+
if (alias.message) {
|
|
74
|
+
const pending = pendingByMessage.get(alias.message);
|
|
75
|
+
const persistedEntryId = pending ? releasePersisted(pending, sessionManager) : undefined;
|
|
76
|
+
if (persistedEntryId) return persistedEntryId;
|
|
77
|
+
}
|
|
78
|
+
return entryId;
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
clearSession({ cwd, sessionId }) {
|
|
82
|
+
const key = scopeKey(cwd, sessionId);
|
|
83
|
+
const scope = aliasesByScope.get(key);
|
|
84
|
+
if (!scope) return;
|
|
85
|
+
for (const alias of scope.values()) {
|
|
86
|
+
if (alias.message) pendingByMessage.delete(alias.message);
|
|
87
|
+
}
|
|
88
|
+
aliasesByScope.delete(key);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
clear() {
|
|
92
|
+
for (const scope of aliasesByScope.values()) {
|
|
93
|
+
for (const alias of scope.values()) {
|
|
94
|
+
if (alias.message) pendingByMessage.delete(alias.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
aliasesByScope.clear();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -140,6 +140,9 @@ export function createSessionRuntimeRegistry({ onSessionEvent = () => {} } = {})
|
|
|
140
140
|
register,
|
|
141
141
|
dispose,
|
|
142
142
|
disposeAll,
|
|
143
|
+
listAll() {
|
|
144
|
+
return [...recordsByKey.values()].map((record) => record.runtime);
|
|
145
|
+
},
|
|
143
146
|
get size() {
|
|
144
147
|
return recordsByKey.size;
|
|
145
148
|
},
|