@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.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/{CommitSection-LewzHfDH.js → CommitSection-DPjTv8fA.js} +1 -1
  3. package/dist/assets/{DiagramEditor-BOIJVnh3.js → DiagramEditor-ChOor9l9.js} +1 -1
  4. package/dist/assets/{DiagramView-CdNHezna.js → DiagramView-3hAT86v_.js} +1 -1
  5. package/dist/assets/{DiffView-Cmw-RHIH.js → DiffView-D1dyOiw2.js} +2 -2
  6. package/dist/assets/FilesView-BCzghrc7.js +14 -0
  7. package/dist/assets/{GitView-DqYF-n_h.js → GitView-CYF1E1p4.js} +4 -4
  8. package/dist/assets/{JsonTreeView-DzEjA1ht.js → JsonTreeView-CrJO6LNu.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-Bfq0bOSM.js → MarkdownRendererImpl-BmKjrkGD.js} +2 -2
  10. package/dist/assets/{PatchDiff-CtVByTC4.js → PatchDiff-DgvPLx6x.js} +1 -1
  11. package/dist/assets/{PierreDiffViewer-Cwoewmiu.js → PierreDiffViewer-InnT0JVA.js} +2 -2
  12. package/dist/assets/{SessionSwitcherDropdown-DNjCJjzF.js → SessionSwitcherDropdown-C0OdvirK.js} +102 -102
  13. package/dist/assets/{SettingsView-BtLvHlj4.js → SettingsView-JAfC_cBL.js} +1 -1
  14. package/dist/assets/{SettingsWindow-CPd7JmJs.js → SettingsWindow-BBEsjcJE.js} +1 -1
  15. package/dist/assets/{ToolOutputDialog-CFFC5eI-.js → ToolOutputDialog-gzwcb8nI.js} +4 -4
  16. package/dist/assets/{ToolPartDiffPreview-yFq1DKSA.js → ToolPartDiffPreview-C45V5bhy.js} +1 -1
  17. package/dist/assets/{VirtualizedFileDiff-CC5lTPJS.js → VirtualizedFileDiff-uKfnkQWb.js} +1 -1
  18. package/dist/assets/{collapsible-D_555Hw_.js → collapsible-Bq8sDiKS.js} +1 -1
  19. package/dist/assets/{computeVirtualFileMetrics-BcVuV0Ic.js → computeVirtualFileMetrics-BPgZ_RSe.js} +2 -2
  20. package/dist/assets/{contextFileOpenGuard-BygvjHuE.js → contextFileOpenGuard-CydO7PkI.js} +1 -1
  21. package/dist/assets/{index-C9CLY-is.js → index-BSuNNtBh.js} +1 -1
  22. package/dist/assets/{index-DQr8-8RF.js → index-CH7o4-_q.js} +1 -1
  23. package/dist/assets/index-o4Eea60G.css +1 -0
  24. package/dist/assets/{isDiffPlainText-Bher0UxW.js → isDiffPlainText-f9b4cKNo.js} +1 -1
  25. package/dist/assets/{isFilePlainText-EIViqOlI.js → isFilePlainText-BjESb-gU.js} +1 -1
  26. package/dist/assets/{main-1bIgjP48.js → main-B508FBXr.js} +2 -2
  27. package/dist/assets/main-nG4uFyAy.js +133 -0
  28. package/dist/assets/{markdownCore-Bm4FxtK1.js → markdownCore-BFmf8a-O.js} +2 -2
  29. package/dist/assets/{miniChat-B7kKqKHH.js → miniChat-BTlz8IPL.js} +2 -2
  30. package/dist/assets/{mobile-DZPRce5z.js → mobile-DBkrRt80.js} +2 -2
  31. package/dist/assets/renderElectronMiniChatApp-DW3fSR5w.js +1 -0
  32. package/dist/assets/renderMobileApp-9qkroKty.js +14 -0
  33. package/dist/assets/{runtimeSurface-B6sKTjo7.js → runtimeSurface-Bec4O8FD.js} +1 -1
  34. package/dist/assets/{useInlineCommentController-CSR_Ju0a.js → useInlineCommentController-CHmIm0Rr.js} +2 -2
  35. package/dist/assets/usePanelSlide-DCX8qQ4K.js +56 -0
  36. package/dist/assets/{useWindowTitle-rt_8xbd9.js → useWindowTitle-CE5N_NeU.js} +3 -3
  37. package/dist/assets/{vendor-codemirror-autocomplete-3xMyV7a2.js → vendor-codemirror-autocomplete-BS2SpSAZ.js} +1 -1
  38. package/dist/assets/{vendor-codemirror-commands-Bzf12ODH.js → vendor-codemirror-commands-RrgJ8H_6.js} +1 -1
  39. package/dist/assets/{vendor-codemirror-lang-angular-0K6s6cvb.js → vendor-codemirror-lang-angular-ADhO_TUK.js} +1 -1
  40. package/dist/assets/{vendor-codemirror-lang-cpp-B0aL03z-.js → vendor-codemirror-lang-cpp-BGCWj7Om.js} +1 -1
  41. package/dist/assets/{vendor-codemirror-lang-css-C4jEScp7.js → vendor-codemirror-lang-css-Ba8JZ6fp.js} +1 -1
  42. package/dist/assets/{vendor-codemirror-lang-go-CgrfVBPr.js → vendor-codemirror-lang-go-DyTiHXo4.js} +1 -1
  43. package/dist/assets/{vendor-codemirror-lang-html-C1Svvdey.js → vendor-codemirror-lang-html-DK27qQri.js} +1 -1
  44. package/dist/assets/{vendor-codemirror-lang-java-DNPz7_Bx.js → vendor-codemirror-lang-java-CvFfnhPQ.js} +1 -1
  45. package/dist/assets/{vendor-codemirror-lang-javascript-CrbeaEAU.js → vendor-codemirror-lang-javascript-BUlYhQ3o.js} +1 -1
  46. package/dist/assets/{vendor-codemirror-lang-jinja-DL8BR_RG.js → vendor-codemirror-lang-jinja-wBMtAEvT.js} +1 -1
  47. package/dist/assets/{vendor-codemirror-lang-json-BTvAjwkJ.js → vendor-codemirror-lang-json-CqayksDf.js} +1 -1
  48. package/dist/assets/{vendor-codemirror-lang-less-CPABt6NZ.js → vendor-codemirror-lang-less-Ct3-H1PB.js} +1 -1
  49. package/dist/assets/{vendor-codemirror-lang-liquid-BLOLOBMg.js → vendor-codemirror-lang-liquid-XspREksq.js} +1 -1
  50. package/dist/assets/{vendor-codemirror-lang-markdown-BloD9GN-.js → vendor-codemirror-lang-markdown-CvO36Y1p.js} +1 -1
  51. package/dist/assets/{vendor-codemirror-lang-php-FIZMTuWM.js → vendor-codemirror-lang-php-BlUF9cCe.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-lang-python-CR2g6ukK.js → vendor-codemirror-lang-python-_2bIYTiJ.js} +1 -1
  53. package/dist/assets/{vendor-codemirror-lang-rust-lsPqn4k4.js → vendor-codemirror-lang-rust-BdTYih_U.js} +1 -1
  54. package/dist/assets/{vendor-codemirror-lang-sass-CtECMwzH.js → vendor-codemirror-lang-sass-E6MhfOFk.js} +1 -1
  55. package/dist/assets/{vendor-codemirror-lang-sql-BtmZrrZH.js → vendor-codemirror-lang-sql-B1BRhQSF.js} +1 -1
  56. package/dist/assets/{vendor-codemirror-lang-vue-Br6DrntY.js → vendor-codemirror-lang-vue-BYQJRa1Q.js} +1 -1
  57. package/dist/assets/{vendor-codemirror-lang-wast-DT7IlCnb.js → vendor-codemirror-lang-wast-D706yuyC.js} +1 -1
  58. package/dist/assets/{vendor-codemirror-lang-xml-wNr-LZOf.js → vendor-codemirror-lang-xml--MfWU6Dj.js} +1 -1
  59. package/dist/assets/{vendor-codemirror-lang-yaml-BTKFwfc2.js → vendor-codemirror-lang-yaml-CRUFIbRX.js} +1 -1
  60. package/dist/assets/{vendor-codemirror-language-BuDklWi8.js → vendor-codemirror-language-E2vgV9K3.js} +1 -1
  61. package/dist/assets/{vendor-codemirror-language-data-Be-zDX0u.js → vendor-codemirror-language-data-q1yHU1R9.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-search-BzyfPoRb.js → vendor-codemirror-search-8kyQdB3f.js} +1 -1
  63. package/dist/assets/{vendor-codemirror-state-Wdz6Qsow.js → vendor-codemirror-state-CNWM3Q8V.js} +1 -1
  64. package/dist/assets/{vendor-codemirror-view-pXtfpqbT.js → vendor-codemirror-view-DSzLbZiS.js} +1 -1
  65. package/dist/assets/{vendor-replit-codemirror-vim-BUqe2Slv.js → vendor-replit-codemirror-vim-BJY5fGLS.js} +1 -1
  66. package/dist/assets/{web-components-DgFD1Jms.js → web-components-CLirSfwt.js} +1 -1
  67. package/dist/index.html +4 -4
  68. package/dist/mini-chat.html +3 -3
  69. package/dist/mobile.html +3 -3
  70. package/package.json +1 -1
  71. package/server/index.js +6 -1
  72. package/server/lib/pi/routes.js +13 -1
  73. package/server/lib/pi/routes.test.js +4 -0
  74. package/server/lib/pi/session-daemon/DOCUMENTATION.md +6 -5
  75. package/server/lib/pi/session-daemon/daemon-process.js +4 -1
  76. package/server/lib/pi/session-daemon/session-daemon.js +138 -20
  77. package/server/lib/pi/session-daemon/session-daemon.test.js +208 -0
  78. package/server/lib/pi/session-daemon/session-jsonl.js +258 -6
  79. package/server/lib/pi/session-daemon/session-jsonl.test.js +69 -1
  80. package/server/lib/pi/session-daemon/supervisor.js +41 -21
  81. package/server/lib/pi/session-daemon/supervisor.test.js +28 -0
  82. package/dist/assets/FilesView-BODbL0Xm.js +0 -14
  83. package/dist/assets/index-DYrJXRkU.css +0 -1
  84. package/dist/assets/main-JDtt2bQS.js +0 -133
  85. package/dist/assets/renderElectronMiniChatApp-Dq0GdThJ.js +0 -1
  86. package/dist/assets/renderMobileApp-DlKnjObC.js +0 -23
  87. 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.type !== 'session' || typeof entry.id !== 'string' || entry.id.length === 0 || typeof entry.cwd !== 'string') {
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 validatePiSessionJsonlFile(join(sessionDirectory, entry.name));
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(validatePiSessionJsonlDirectory({ cwd, agentDir })).rejects.toMatchObject({
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
- return { state: 'ready', reused: true, protocolVersion: PROTOCOL_VERSION, capabilities: existing.health.capabilities ?? [] };
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
- return { credential, ready };
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
- try {
439
- processLike.kill(state.pid, 'SIGTERM');
440
- } catch {
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');