@pi-chamber/web 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/assets/{CommitSection-LewzHfDH.js → CommitSection-DPjTv8fA.js} +1 -1
- package/dist/assets/{DiagramEditor-BOIJVnh3.js → DiagramEditor-ChOor9l9.js} +1 -1
- package/dist/assets/{DiagramView-CdNHezna.js → DiagramView-3hAT86v_.js} +1 -1
- package/dist/assets/{DiffView-Cmw-RHIH.js → DiffView-D1dyOiw2.js} +2 -2
- package/dist/assets/FilesView-BCzghrc7.js +14 -0
- package/dist/assets/{GitView-DqYF-n_h.js → GitView-CYF1E1p4.js} +4 -4
- package/dist/assets/{JsonTreeView-DzEjA1ht.js → JsonTreeView-CrJO6LNu.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-Bfq0bOSM.js → MarkdownRendererImpl-BmKjrkGD.js} +2 -2
- package/dist/assets/{PatchDiff-CtVByTC4.js → PatchDiff-DgvPLx6x.js} +1 -1
- package/dist/assets/{PierreDiffViewer-Cwoewmiu.js → PierreDiffViewer-InnT0JVA.js} +2 -2
- package/dist/assets/{SessionSwitcherDropdown-DNjCJjzF.js → SessionSwitcherDropdown-C0OdvirK.js} +102 -102
- package/dist/assets/{SettingsView-BtLvHlj4.js → SettingsView-JAfC_cBL.js} +1 -1
- package/dist/assets/{SettingsWindow-CPd7JmJs.js → SettingsWindow-BBEsjcJE.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CFFC5eI-.js → ToolOutputDialog-gzwcb8nI.js} +4 -4
- package/dist/assets/{ToolPartDiffPreview-yFq1DKSA.js → ToolPartDiffPreview-C45V5bhy.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-CC5lTPJS.js → VirtualizedFileDiff-uKfnkQWb.js} +1 -1
- package/dist/assets/{collapsible-D_555Hw_.js → collapsible-Bq8sDiKS.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-BcVuV0Ic.js → computeVirtualFileMetrics-BPgZ_RSe.js} +2 -2
- package/dist/assets/{contextFileOpenGuard-BygvjHuE.js → contextFileOpenGuard-CydO7PkI.js} +1 -1
- package/dist/assets/{index-C9CLY-is.js → index-BSuNNtBh.js} +1 -1
- package/dist/assets/{index-DQr8-8RF.js → index-CH7o4-_q.js} +1 -1
- package/dist/assets/index-o4Eea60G.css +1 -0
- package/dist/assets/{isDiffPlainText-Bher0UxW.js → isDiffPlainText-f9b4cKNo.js} +1 -1
- package/dist/assets/{isFilePlainText-EIViqOlI.js → isFilePlainText-BjESb-gU.js} +1 -1
- package/dist/assets/{main-1bIgjP48.js → main-B508FBXr.js} +2 -2
- package/dist/assets/main-nG4uFyAy.js +133 -0
- package/dist/assets/{markdownCore-Bm4FxtK1.js → markdownCore-BFmf8a-O.js} +2 -2
- package/dist/assets/{miniChat-B7kKqKHH.js → miniChat-BTlz8IPL.js} +2 -2
- package/dist/assets/{mobile-DZPRce5z.js → mobile-DBkrRt80.js} +2 -2
- package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +1 -0
- package/dist/assets/renderMobileApp-9qkroKty.js +14 -0
- package/dist/assets/{runtimeSurface-B6sKTjo7.js → runtimeSurface-Bec4O8FD.js} +1 -1
- package/dist/assets/{useInlineCommentController-CSR_Ju0a.js → useInlineCommentController-CHmIm0Rr.js} +2 -2
- package/dist/assets/usePanelSlide-DCX8qQ4K.js +56 -0
- package/dist/assets/{useWindowTitle-rt_8xbd9.js → useWindowTitle-CE5N_NeU.js} +3 -3
- package/dist/assets/{vendor-codemirror-autocomplete-3xMyV7a2.js → vendor-codemirror-autocomplete-BS2SpSAZ.js} +1 -1
- package/dist/assets/{vendor-codemirror-commands-Bzf12ODH.js → vendor-codemirror-commands-RrgJ8H_6.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-angular-0K6s6cvb.js → vendor-codemirror-lang-angular-ADhO_TUK.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-cpp-B0aL03z-.js → vendor-codemirror-lang-cpp-BGCWj7Om.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-css-C4jEScp7.js → vendor-codemirror-lang-css-Ba8JZ6fp.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-CgrfVBPr.js → vendor-codemirror-lang-go-DyTiHXo4.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-html-C1Svvdey.js → vendor-codemirror-lang-html-DK27qQri.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-java-DNPz7_Bx.js → vendor-codemirror-lang-java-CvFfnhPQ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-javascript-CrbeaEAU.js → vendor-codemirror-lang-javascript-BUlYhQ3o.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-jinja-DL8BR_RG.js → vendor-codemirror-lang-jinja-wBMtAEvT.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-BTvAjwkJ.js → vendor-codemirror-lang-json-CqayksDf.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-CPABt6NZ.js → vendor-codemirror-lang-less-Ct3-H1PB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-BLOLOBMg.js → vendor-codemirror-lang-liquid-XspREksq.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-markdown-BloD9GN-.js → vendor-codemirror-lang-markdown-CvO36Y1p.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-php-FIZMTuWM.js → vendor-codemirror-lang-php-BlUF9cCe.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-python-CR2g6ukK.js → vendor-codemirror-lang-python-_2bIYTiJ.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-rust-lsPqn4k4.js → vendor-codemirror-lang-rust-BdTYih_U.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sass-CtECMwzH.js → vendor-codemirror-lang-sass-E6MhfOFk.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-sql-BtmZrrZH.js → vendor-codemirror-lang-sql-B1BRhQSF.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-vue-Br6DrntY.js → vendor-codemirror-lang-vue-BYQJRa1Q.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-DT7IlCnb.js → vendor-codemirror-lang-wast-D706yuyC.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-xml-wNr-LZOf.js → vendor-codemirror-lang-xml--MfWU6Dj.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-yaml-BTKFwfc2.js → vendor-codemirror-lang-yaml-CRUFIbRX.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-BuDklWi8.js → vendor-codemirror-language-E2vgV9K3.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Be-zDX0u.js → vendor-codemirror-language-data-q1yHU1R9.js} +2 -2
- package/dist/assets/{vendor-codemirror-search-BzyfPoRb.js → vendor-codemirror-search-8kyQdB3f.js} +1 -1
- package/dist/assets/{vendor-codemirror-state-Wdz6Qsow.js → vendor-codemirror-state-CNWM3Q8V.js} +1 -1
- package/dist/assets/{vendor-codemirror-view-pXtfpqbT.js → vendor-codemirror-view-DSzLbZiS.js} +1 -1
- package/dist/assets/{vendor-replit-codemirror-vim-BUqe2Slv.js → vendor-replit-codemirror-vim-BJY5fGLS.js} +1 -1
- package/dist/assets/{web-components-DgFD1Jms.js → web-components-CLirSfwt.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/index.js +6 -1
- package/server/lib/pi/routes.js +13 -1
- package/server/lib/pi/routes.test.js +4 -0
- package/server/lib/pi/session-daemon/DOCUMENTATION.md +6 -5
- package/server/lib/pi/session-daemon/daemon-process.js +4 -1
- package/server/lib/pi/session-daemon/session-daemon.js +138 -20
- package/server/lib/pi/session-daemon/session-daemon.test.js +208 -0
- package/server/lib/pi/session-daemon/session-jsonl.js +258 -6
- package/server/lib/pi/session-daemon/session-jsonl.test.js +69 -1
- package/server/lib/pi/session-daemon/supervisor.js +41 -21
- package/server/lib/pi/session-daemon/supervisor.test.js +28 -0
- package/dist/assets/FilesView-BODbL0Xm.js +0 -14
- package/dist/assets/index-DYrJXRkU.css +0 -1
- package/dist/assets/main-JDtt2bQS.js +0 -133
- package/dist/assets/renderElectronMiniChatApp-Dq0GdThJ.js +0 -1
- package/dist/assets/renderMobileApp-DlKnjObC.js +0 -23
- package/dist/assets/usePanelSlide-BkonU4LS.js +0 -47
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
|
-
import { mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, open, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
|
|
@@ -33,11 +33,132 @@ class SessionJsonlError extends Error {
|
|
|
33
33
|
const malformed = () => new SessionJsonlError('MALFORMED_SESSION_JSONL');
|
|
34
34
|
const unreadable = () => new SessionJsonlError('SESSION_JSONL_UNREADABLE');
|
|
35
35
|
|
|
36
|
+
/** Stop looking for the first user prompt after this many leading bytes. */
|
|
37
|
+
const LIST_HEAD_SCAN_BYTES = 512 * 1024;
|
|
38
|
+
/** Latest `session_info` (rename) is appended; read a tail instead of the whole log. */
|
|
39
|
+
const LIST_TAIL_SCAN_BYTES = 128 * 1024;
|
|
40
|
+
const LIST_PREVIEW_CHARS = 500;
|
|
41
|
+
|
|
42
|
+
const isSessionHeader = (entry) => (
|
|
43
|
+
Boolean(entry)
|
|
44
|
+
&& !Array.isArray(entry)
|
|
45
|
+
&& typeof entry === 'object'
|
|
46
|
+
&& entry.type === 'session'
|
|
47
|
+
&& typeof entry.id === 'string'
|
|
48
|
+
&& entry.id.length > 0
|
|
49
|
+
&& typeof entry.cwd === 'string'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const extractUserPreview = (message) => {
|
|
53
|
+
const content = message?.content;
|
|
54
|
+
if (typeof content === 'string') return content.trim();
|
|
55
|
+
if (!Array.isArray(content)) return '';
|
|
56
|
+
return content
|
|
57
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
58
|
+
.map((block) => block.text)
|
|
59
|
+
.join(' ')
|
|
60
|
+
.trim();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const trimPreview = (text) => {
|
|
64
|
+
if (!text) return undefined;
|
|
65
|
+
return text.length > LIST_PREVIEW_CHARS ? text.slice(0, LIST_PREVIEW_CHARS) : text;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const sessionInfoName = (entry) => {
|
|
69
|
+
if (!entry || entry.type !== 'session_info') return undefined;
|
|
70
|
+
if (typeof entry.name !== 'string') return undefined;
|
|
71
|
+
const name = entry.name.trim();
|
|
72
|
+
return name.length > 0 ? name : undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
36
75
|
/**
|
|
37
76
|
* Validates the JSONL files Pi discovers for a cwd before granting that
|
|
38
77
|
* discovery result authority. Pi's discovery intentionally skips malformed
|
|
39
78
|
* files; the daemon must instead surface them as an explicit failure.
|
|
79
|
+
* List/startup/create only require a valid session header. Full-file
|
|
80
|
+
* validation stays on open of a chosen transcript so a 16MB log cannot
|
|
81
|
+
* block every `sessions.list`.
|
|
82
|
+
*/
|
|
83
|
+
export const matchesPiSessionJsonlName = (fileName, sessionId) => (
|
|
84
|
+
typeof fileName === 'string'
|
|
85
|
+
&& typeof sessionId === 'string'
|
|
86
|
+
&& sessionId.length > 0
|
|
87
|
+
&& fileName.endsWith('.jsonl')
|
|
88
|
+
&& (fileName === `${sessionId}.jsonl` || fileName.endsWith(`_${sessionId}.jsonl`))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read only the session header. Callers that are looking up an id must
|
|
93
|
+
* not scan the rest of a multi-megabyte transcript.
|
|
40
94
|
*/
|
|
95
|
+
async function readPiSessionJsonlHeader(filePath) {
|
|
96
|
+
const input = createReadStream(filePath, { encoding: 'utf8' });
|
|
97
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
98
|
+
try {
|
|
99
|
+
for await (const line of lines) {
|
|
100
|
+
if (!line.trim()) continue;
|
|
101
|
+
let entry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
throw malformed();
|
|
106
|
+
}
|
|
107
|
+
if (!isSessionHeader(entry)) throw malformed();
|
|
108
|
+
return entry;
|
|
109
|
+
}
|
|
110
|
+
throw malformed();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof SessionJsonlError) throw error;
|
|
113
|
+
throw unreadable();
|
|
114
|
+
} finally {
|
|
115
|
+
lines.close();
|
|
116
|
+
input.destroy();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Locate a persisted Pi session by the SDK filename (`<timestamp>_<id>.jsonl`
|
|
122
|
+
* or `<id>.jsonl`) without listing or fully reading every transcript. A miss
|
|
123
|
+
* is `null`, not an empty success.
|
|
124
|
+
*/
|
|
125
|
+
export async function findPiSessionJsonlById({ sessionId, agentDir, resolvePath = resolve }) {
|
|
126
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0 || typeof agentDir !== 'string') {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const sessionsRoot = join(resolvePath(agentDir), 'sessions');
|
|
130
|
+
let dirEntries;
|
|
131
|
+
try {
|
|
132
|
+
dirEntries = await readdir(sessionsRoot, { withFileTypes: true });
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
for (const dirEntry of dirEntries) {
|
|
137
|
+
if (!dirEntry.isDirectory()) continue;
|
|
138
|
+
const dirPath = join(sessionsRoot, dirEntry.name);
|
|
139
|
+
let files;
|
|
140
|
+
try {
|
|
141
|
+
files = await readdir(dirPath, { withFileTypes: true });
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
if (file.isDirectory()) continue;
|
|
147
|
+
if (!matchesPiSessionJsonlName(file.name, sessionId)) continue;
|
|
148
|
+
const fullPath = join(dirPath, file.name);
|
|
149
|
+
try {
|
|
150
|
+
const header = await readPiSessionJsonlHeader(fullPath);
|
|
151
|
+
if (header.id === sessionId) {
|
|
152
|
+
return { id: sessionId, path: fullPath, cwd: header.cwd };
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
41
162
|
export async function validatePiSessionJsonlFile(filePath) {
|
|
42
163
|
let sawHeader = false;
|
|
43
164
|
try {
|
|
@@ -53,9 +174,7 @@ export async function validatePiSessionJsonlFile(filePath) {
|
|
|
53
174
|
}
|
|
54
175
|
if (!entry || Array.isArray(entry) || typeof entry !== 'object') throw malformed();
|
|
55
176
|
if (!sawHeader) {
|
|
56
|
-
if (entry
|
|
57
|
-
throw malformed();
|
|
58
|
-
}
|
|
177
|
+
if (!isSessionHeader(entry)) throw malformed();
|
|
59
178
|
sawHeader = true;
|
|
60
179
|
}
|
|
61
180
|
}
|
|
@@ -66,6 +185,139 @@ export async function validatePiSessionJsonlFile(filePath) {
|
|
|
66
185
|
if (!sawHeader) throw malformed();
|
|
67
186
|
}
|
|
68
187
|
|
|
188
|
+
async function readLatestSessionInfoFromTail(filePath, fileSize) {
|
|
189
|
+
const tailSize = Math.min(fileSize, LIST_TAIL_SCAN_BYTES);
|
|
190
|
+
if (tailSize <= 0) return null;
|
|
191
|
+
let handle;
|
|
192
|
+
try {
|
|
193
|
+
handle = await open(filePath, 'r');
|
|
194
|
+
const buffer = Buffer.alloc(tailSize);
|
|
195
|
+
const { bytesRead } = await handle.read(buffer, 0, tailSize, fileSize - tailSize);
|
|
196
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
197
|
+
const lines = text.split('\n');
|
|
198
|
+
let seen = false;
|
|
199
|
+
let name;
|
|
200
|
+
for (const line of lines.slice(fileSize > tailSize ? 1 : 0)) {
|
|
201
|
+
if (!line.trim()) continue;
|
|
202
|
+
try {
|
|
203
|
+
const entry = JSON.parse(line);
|
|
204
|
+
if (entry?.type === 'session_info') {
|
|
205
|
+
seen = true;
|
|
206
|
+
name = sessionInfoName(entry);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return seen ? { name } : null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
} finally {
|
|
216
|
+
await handle?.close().catch(() => {});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readPiSessionJsonlListFields(filePath, fileSize) {
|
|
221
|
+
const input = createReadStream(filePath, { encoding: 'utf8' });
|
|
222
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
223
|
+
let header;
|
|
224
|
+
let name;
|
|
225
|
+
let firstMessage;
|
|
226
|
+
let bytes = 0;
|
|
227
|
+
const scanWholePrefix = fileSize <= LIST_HEAD_SCAN_BYTES;
|
|
228
|
+
try {
|
|
229
|
+
for await (const line of lines) {
|
|
230
|
+
bytes += Buffer.byteLength(line) + 1;
|
|
231
|
+
if (!line.trim()) {
|
|
232
|
+
if (!scanWholePrefix && bytes >= LIST_HEAD_SCAN_BYTES) break;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
let entry;
|
|
236
|
+
try {
|
|
237
|
+
entry = JSON.parse(line);
|
|
238
|
+
} catch {
|
|
239
|
+
if (!header) throw malformed();
|
|
240
|
+
if (!scanWholePrefix && bytes >= LIST_HEAD_SCAN_BYTES) break;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!header) {
|
|
244
|
+
if (!isSessionHeader(entry)) throw malformed();
|
|
245
|
+
header = entry;
|
|
246
|
+
} else if (entry?.type === 'session_info') {
|
|
247
|
+
name = sessionInfoName(entry);
|
|
248
|
+
} else if (!firstMessage && entry?.type === 'message' && entry.message?.role === 'user') {
|
|
249
|
+
const preview = extractUserPreview(entry.message);
|
|
250
|
+
if (preview) firstMessage = trimPreview(preview);
|
|
251
|
+
}
|
|
252
|
+
if (!scanWholePrefix && header && firstMessage) break;
|
|
253
|
+
if (!scanWholePrefix && bytes >= LIST_HEAD_SCAN_BYTES) break;
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof SessionJsonlError) throw error;
|
|
257
|
+
throw unreadable();
|
|
258
|
+
} finally {
|
|
259
|
+
lines.close();
|
|
260
|
+
input.destroy();
|
|
261
|
+
}
|
|
262
|
+
if (!header) throw malformed();
|
|
263
|
+
return { header, name, firstMessage };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Sidebar/list metadata for one cwd. Reads a header, a bounded prefix for
|
|
268
|
+
* the first user prompt, and a tail for the latest rename. It does not
|
|
269
|
+
* load whole transcripts.
|
|
270
|
+
*/
|
|
271
|
+
export async function listPiSessionJsonlDirectory({
|
|
272
|
+
cwd,
|
|
273
|
+
agentDir,
|
|
274
|
+
platform = process.platform,
|
|
275
|
+
resolvePath = resolve,
|
|
276
|
+
} = {}) {
|
|
277
|
+
let sessionDirectory;
|
|
278
|
+
let entries;
|
|
279
|
+
try {
|
|
280
|
+
sessionDirectory = getPiSessionDirectory({ cwd, agentDir, platform, resolvePath });
|
|
281
|
+
await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
|
|
282
|
+
entries = await readdir(sessionDirectory, { withFileTypes: true });
|
|
283
|
+
} catch {
|
|
284
|
+
throw unreadable();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const sessions = [];
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (!entry.name.endsWith('.jsonl') || entry.isDirectory()) continue;
|
|
290
|
+
const filePath = join(sessionDirectory, entry.name);
|
|
291
|
+
let fileStat;
|
|
292
|
+
try {
|
|
293
|
+
fileStat = await stat(filePath);
|
|
294
|
+
} catch {
|
|
295
|
+
throw unreadable();
|
|
296
|
+
}
|
|
297
|
+
const fields = await readPiSessionJsonlListFields(filePath, fileStat.size);
|
|
298
|
+
const tail = fileStat.size > LIST_HEAD_SCAN_BYTES
|
|
299
|
+
? await readLatestSessionInfoFromTail(filePath, fileStat.size)
|
|
300
|
+
: null;
|
|
301
|
+
const name = tail?.name ?? fields.name;
|
|
302
|
+
const headerTime = typeof fields.header.timestamp === 'string'
|
|
303
|
+
? Date.parse(fields.header.timestamp)
|
|
304
|
+
: NaN;
|
|
305
|
+
const created = Number.isFinite(headerTime) ? new Date(headerTime) : fileStat.mtime;
|
|
306
|
+
sessions.push({
|
|
307
|
+
path: filePath,
|
|
308
|
+
id: fields.header.id,
|
|
309
|
+
cwd: fields.header.cwd,
|
|
310
|
+
...(name ? { name } : {}),
|
|
311
|
+
...(typeof fields.header.parentSession === 'string' ? { parentSessionPath: fields.header.parentSession } : {}),
|
|
312
|
+
created,
|
|
313
|
+
modified: fileStat.mtime,
|
|
314
|
+
...(fields.firstMessage ? { firstMessage: fields.firstMessage } : {}),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
sessions.sort((left, right) => right.modified.getTime() - left.modified.getTime());
|
|
318
|
+
return sessions;
|
|
319
|
+
}
|
|
320
|
+
|
|
69
321
|
export const getPiSessionDirectory = ({
|
|
70
322
|
cwd,
|
|
71
323
|
agentDir,
|
|
@@ -88,7 +340,7 @@ export async function validatePiSessionJsonlDirectory({ cwd, agentDir }) {
|
|
|
88
340
|
}
|
|
89
341
|
|
|
90
342
|
for (const entry of entries) {
|
|
91
|
-
if (!entry.name.endsWith('.jsonl')) continue;
|
|
92
|
-
await
|
|
343
|
+
if (!entry.name.endsWith('.jsonl') || entry.isDirectory()) continue;
|
|
344
|
+
await readPiSessionJsonlHeader(join(sessionDirectory, entry.name));
|
|
93
345
|
}
|
|
94
346
|
}
|
|
@@ -6,7 +6,10 @@ import { describe, expect, it } from 'vitest';
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
encodePiSessionCwd,
|
|
9
|
+
findPiSessionJsonlById,
|
|
9
10
|
getPiSessionDirectory,
|
|
11
|
+
listPiSessionJsonlDirectory,
|
|
12
|
+
matchesPiSessionJsonlName,
|
|
10
13
|
normalizeWindowsShellPath,
|
|
11
14
|
validatePiSessionJsonlDirectory,
|
|
12
15
|
validatePiSessionJsonlFile,
|
|
@@ -58,9 +61,15 @@ describe('Pi session JSONL validation', () => {
|
|
|
58
61
|
await mkdir(sessionDirectory, { recursive: true });
|
|
59
62
|
await writeFile(join(sessionDirectory, 'corrupt.jsonl'), `${validHeader(cwd)}\nnot-json\n`);
|
|
60
63
|
|
|
61
|
-
await expect(
|
|
64
|
+
await expect(validatePiSessionJsonlFile(join(sessionDirectory, 'corrupt.jsonl'))).rejects.toMatchObject({
|
|
62
65
|
code: 'MALFORMED_SESSION_JSONL',
|
|
63
66
|
});
|
|
67
|
+
// Directory list/startup only require a valid header so a later corrupt
|
|
68
|
+
// line cannot make every sessions.list read 16MB transcripts.
|
|
69
|
+
await expect(validatePiSessionJsonlDirectory({ cwd, agentDir })).resolves.toBeUndefined();
|
|
70
|
+
await expect(listPiSessionJsonlDirectory({ cwd, agentDir })).resolves.toEqual([
|
|
71
|
+
expect.objectContaining({ id: 'session-one', cwd }),
|
|
72
|
+
]);
|
|
64
73
|
});
|
|
65
74
|
|
|
66
75
|
it('reports unreadable session files explicitly', async () => {
|
|
@@ -70,4 +79,63 @@ describe('Pi session JSONL validation', () => {
|
|
|
70
79
|
code: 'SESSION_JSONL_UNREADABLE',
|
|
71
80
|
});
|
|
72
81
|
});
|
|
82
|
+
|
|
83
|
+
it('matches Pi SessionManager filenames without treating session-1 as session-10', () => {
|
|
84
|
+
expect(matchesPiSessionJsonlName('2026-01-01T00-00-00-000Z_session-one.jsonl', 'session-one')).toBe(true);
|
|
85
|
+
expect(matchesPiSessionJsonlName('session-one.jsonl', 'session-one')).toBe(true);
|
|
86
|
+
expect(matchesPiSessionJsonlName('2026-01-01T00-00-00-000Z_session-10.jsonl', 'session-1')).toBe(false);
|
|
87
|
+
expect(matchesPiSessionJsonlName('other.jsonl', 'session-one')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('finds a session by filename without reading unrelated transcripts', async () => {
|
|
91
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-jsonl-'));
|
|
92
|
+
const cwd = join(root, 'project');
|
|
93
|
+
const agentDir = join(root, 'agent');
|
|
94
|
+
const sessionDirectory = getPiSessionDirectory({ cwd, agentDir });
|
|
95
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
96
|
+
const targetId = '01a019b7-ce87-7be9-8fdd-4ed2ce979f5f';
|
|
97
|
+
await writeFile(
|
|
98
|
+
join(sessionDirectory, `2026-08-19T11-13-31-038Z_${targetId}.jsonl`),
|
|
99
|
+
`${JSON.stringify({ type: 'session', version: 3, id: targetId, timestamp: '2026-08-19T11:13:31.038Z', cwd })}\n`,
|
|
100
|
+
);
|
|
101
|
+
await writeFile(join(sessionDirectory, 'unrelated.jsonl'), `${validHeader(cwd)}\n`);
|
|
102
|
+
|
|
103
|
+
await expect(findPiSessionJsonlById({ sessionId: targetId, agentDir })).resolves.toMatchObject({
|
|
104
|
+
id: targetId,
|
|
105
|
+
cwd,
|
|
106
|
+
});
|
|
107
|
+
await expect(findPiSessionJsonlById({ sessionId: '01a019b7-ce87-7be9-8fdd-missing', agentDir })).resolves.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('lists session metadata without reading a padded transcript body', async () => {
|
|
111
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-jsonl-'));
|
|
112
|
+
const cwd = join(root, 'project');
|
|
113
|
+
const agentDir = join(root, 'agent');
|
|
114
|
+
const sessionDirectory = getPiSessionDirectory({ cwd, agentDir });
|
|
115
|
+
await mkdir(sessionDirectory, { recursive: true });
|
|
116
|
+
const padding = `${JSON.stringify({ type: 'message', message: { role: 'assistant', content: 'x'.repeat(1024) } })}\n`.repeat(800);
|
|
117
|
+
await writeFile(
|
|
118
|
+
join(sessionDirectory, '2026-01-01T00-00-00-000Z_session-one.jsonl'),
|
|
119
|
+
[
|
|
120
|
+
validHeader(cwd),
|
|
121
|
+
JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text: 'First prompt about listing' }] } }),
|
|
122
|
+
padding.trimEnd(),
|
|
123
|
+
JSON.stringify({ type: 'session_info', name: 'Renamed from tail' }),
|
|
124
|
+
'',
|
|
125
|
+
].join('\n'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const started = Date.now();
|
|
129
|
+
const listed = await listPiSessionJsonlDirectory({ cwd, agentDir });
|
|
130
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
131
|
+
expect(listed).toEqual([
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
id: 'session-one',
|
|
134
|
+
cwd,
|
|
135
|
+
name: 'Renamed from tail',
|
|
136
|
+
firstMessage: 'First prompt about listing',
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
expect(listed[0].path).toContain('session-one.jsonl');
|
|
140
|
+
});
|
|
73
141
|
});
|
|
@@ -42,6 +42,10 @@ const hasValidStateIdentity = (state) => (
|
|
|
42
42
|
|
|
43
43
|
const isValidState = (state) => hasValidStateIdentity(state) && typeof state.startedAt === 'string';
|
|
44
44
|
|
|
45
|
+
const daemonEntrypointMatches = (state) => (
|
|
46
|
+
typeof state?.entrypoint === 'string' && state.entrypoint === DAEMON_ENTRYPOINT
|
|
47
|
+
);
|
|
48
|
+
|
|
45
49
|
const isValidFailureState = (state) => hasValidStateIdentity(state)
|
|
46
50
|
&& state.state === 'failed'
|
|
47
51
|
&& typeof state.error?.code === 'string';
|
|
@@ -306,6 +310,26 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
306
310
|
return true;
|
|
307
311
|
};
|
|
308
312
|
|
|
313
|
+
const requestDaemonExit = async (state) => {
|
|
314
|
+
if (!state || !Number.isInteger(state.pid) || state.pid <= 0) return;
|
|
315
|
+
try {
|
|
316
|
+
processLike.kill(state.pid, 'SIGTERM');
|
|
317
|
+
} catch {
|
|
318
|
+
if (isPidAlive(processLike, state.pid)) throw new PiSessionDaemonUnavailableError('DAEMON_STOP_FAILED');
|
|
319
|
+
await removeStaleState(state);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
323
|
+
while (Date.now() < deadline) {
|
|
324
|
+
if (!isPidAlive(processLike, state.pid)) {
|
|
325
|
+
await removeStaleState(state);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await wait(RETRY_DELAY_MS);
|
|
329
|
+
}
|
|
330
|
+
throw new PiSessionDaemonUnavailableError('DAEMON_STOP_TIMEOUT');
|
|
331
|
+
};
|
|
332
|
+
|
|
309
333
|
const start = async () => {
|
|
310
334
|
intentionallyStopped = false;
|
|
311
335
|
if (startPromise) return startPromise;
|
|
@@ -313,9 +337,15 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
313
337
|
const credential = await ensureCredential();
|
|
314
338
|
try {
|
|
315
339
|
const existing = await probe(credential);
|
|
316
|
-
|
|
340
|
+
if (daemonEntrypointMatches(existing.state)) {
|
|
341
|
+
return { state: 'ready', reused: true, protocolVersion: PROTOCOL_VERSION, capabilities: existing.health.capabilities ?? [] };
|
|
342
|
+
}
|
|
343
|
+
// A healthy daemon from another install (global npm vs this checkout)
|
|
344
|
+
// must not keep answering getSession without the live-turn overlay.
|
|
345
|
+
await requestDaemonExit(existing.state);
|
|
317
346
|
} catch (error) {
|
|
318
347
|
if (!(error instanceof PiSessionDaemonUnavailableError)) throw error;
|
|
348
|
+
if (error.code === 'DAEMON_STOP_FAILED' || error.code === 'DAEMON_STOP_TIMEOUT') throw error;
|
|
319
349
|
}
|
|
320
350
|
|
|
321
351
|
const staleState = await readState() ?? await readFailureState();
|
|
@@ -386,16 +416,19 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
386
416
|
let credential = await readCredential();
|
|
387
417
|
try {
|
|
388
418
|
const ready = await probe(credential);
|
|
389
|
-
|
|
419
|
+
if (daemonEntrypointMatches(ready.state)) {
|
|
420
|
+
return { credential, ready };
|
|
421
|
+
}
|
|
390
422
|
} catch (probeError) {
|
|
391
423
|
if (intentionallyStopped) throw new PiSessionDaemonUnavailableError('DAEMON_UNAVAILABLE');
|
|
392
424
|
if (probeError instanceof PiSessionDaemonUnavailableError && isPermanentStartupFailure(probeError.code)) {
|
|
393
425
|
throw probeError;
|
|
394
426
|
}
|
|
395
|
-
await start();
|
|
396
|
-
credential = await readCredential();
|
|
397
|
-
return { credential, ready: await probe(credential) };
|
|
398
427
|
}
|
|
428
|
+
if (intentionallyStopped) throw new PiSessionDaemonUnavailableError('DAEMON_UNAVAILABLE');
|
|
429
|
+
await start();
|
|
430
|
+
credential = await readCredential();
|
|
431
|
+
return { credential, ready: await probe(credential) };
|
|
399
432
|
};
|
|
400
433
|
|
|
401
434
|
const requestDaemon = async (command, payload) => {
|
|
@@ -435,22 +468,9 @@ export const createPiSessionDaemonSupervisor = ({
|
|
|
435
468
|
return withOperationLock(async () => {
|
|
436
469
|
const credential = await readCredential();
|
|
437
470
|
const { state } = await probe(credential);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
throw new PiSessionDaemonUnavailableError('DAEMON_STOP_FAILED');
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const deadline = Date.now() + startupTimeoutMs;
|
|
445
|
-
while (Date.now() < deadline) {
|
|
446
|
-
if (!isPidAlive(processLike, state.pid)) {
|
|
447
|
-
await removeStaleState(state);
|
|
448
|
-
intentionallyStopped = true;
|
|
449
|
-
return { state: 'stopped' };
|
|
450
|
-
}
|
|
451
|
-
await wait(RETRY_DELAY_MS);
|
|
452
|
-
}
|
|
453
|
-
throw new PiSessionDaemonUnavailableError('DAEMON_STOP_TIMEOUT');
|
|
471
|
+
await requestDaemonExit(state);
|
|
472
|
+
intentionallyStopped = true;
|
|
473
|
+
return { state: 'stopped' };
|
|
454
474
|
});
|
|
455
475
|
};
|
|
456
476
|
|
|
@@ -110,6 +110,34 @@ describe('Pi session daemon supervisor', () => {
|
|
|
110
110
|
});
|
|
111
111
|
}, 20_000);
|
|
112
112
|
|
|
113
|
+
it('replaces a healthy daemon that predates entrypoint identity', async () => {
|
|
114
|
+
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-entrypoint-'));
|
|
115
|
+
const cwd = join(root, 'project');
|
|
116
|
+
const agentDir = join(root, 'agent');
|
|
117
|
+
await Promise.all([mkdir(cwd), mkdir(agentDir)]);
|
|
118
|
+
const env = {
|
|
119
|
+
...process.env,
|
|
120
|
+
PI_OFFLINE: '1',
|
|
121
|
+
PICHAMBER_DATA_DIR: join(root, 'data'),
|
|
122
|
+
PICHAMBER_PI_AGENT_DIR: agentDir,
|
|
123
|
+
XDG_RUNTIME_DIR: join(root, 'runtime'),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
supervisor = createPiSessionDaemonSupervisor({ env, cwd });
|
|
127
|
+
await expect(supervisor.start()).resolves.toMatchObject({ state: 'ready', reused: false });
|
|
128
|
+
const first = JSON.parse(await readFile(supervisor.paths.stateFile, 'utf8'));
|
|
129
|
+
expect(typeof first.entrypoint).toBe('string');
|
|
130
|
+
const firstPid = first.pid;
|
|
131
|
+
const { entrypoint: _entrypoint, ...legacyState } = first;
|
|
132
|
+
await writeFile(supervisor.paths.stateFile, JSON.stringify(legacyState));
|
|
133
|
+
|
|
134
|
+
await expect(supervisor.start()).resolves.toMatchObject({ state: 'ready', reused: false });
|
|
135
|
+
await waitForExit(firstPid);
|
|
136
|
+
const second = JSON.parse(await readFile(supervisor.paths.stateFile, 'utf8'));
|
|
137
|
+
expect(second.pid).not.toBe(firstPid);
|
|
138
|
+
expect(second.entrypoint).toBe(first.entrypoint);
|
|
139
|
+
}, 20_000);
|
|
140
|
+
|
|
113
141
|
it('spawns the daemon as Node when the parent process is Electron', async () => {
|
|
114
142
|
const root = await mkdtemp(join(tmpdir(), 'pichamber-pi-supervisor-electron-'));
|
|
115
143
|
const cwd = join(root, 'project');
|