pi-kanban 0.1.0

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.
@@ -0,0 +1,889 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+ const readline = require('node:readline');
5
+ const gitBranchModule = require('./git-branch');
6
+ const taskStore = require('./task-store');
7
+
8
+ // Optional callback (cwd, branch) -> void, set by server.js to push SSE updates when branches resolve.
9
+ let onBranchResolved = null;
10
+ function setOnBranchResolved(fn) { onBranchResolved = typeof fn === 'function' ? fn : null; }
11
+
12
+ const PI_DIR = process.env.PI_DIR || path.join(os.homedir(), '.pi');
13
+ const SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
14
+
15
+ function getPiDir() {
16
+ return PI_DIR;
17
+ }
18
+
19
+ function getSessionsDir() {
20
+ return SESSIONS_DIR;
21
+ }
22
+
23
+ // "--C--Users-nikiforovall-dev-pi--" -> "C:\Users\nikiforovall\dev\pi"
24
+ function decodeProjectDir(name) {
25
+ let s = name;
26
+ if (s.startsWith('--')) s = s.slice(2);
27
+ if (s.endsWith('--')) s = s.slice(0, -2);
28
+ // First "C" (or any single letter) at start represents drive letter, then dashes are path separators.
29
+ // The convention is: drive letter + "-" then path with "/" replaced by "-".
30
+ // e.g. "C--Users-nikiforovall-dev-pi" -> "C:\Users\nikiforovall\dev\pi"
31
+ const m = s.match(/^([A-Za-z])--(.+)$/);
32
+ if (m) {
33
+ return `${m[1]}:\\${m[2].replace(/-/g, '\\')}`;
34
+ }
35
+ return s.replace(/-/g, path.sep);
36
+ }
37
+
38
+ function listSessionFiles() {
39
+ if (!fs.existsSync(SESSIONS_DIR)) return [];
40
+ const out = [];
41
+ for (const projDir of fs.readdirSync(SESSIONS_DIR)) {
42
+ const projPath = path.join(SESSIONS_DIR, projDir);
43
+ let stat;
44
+ try { stat = fs.statSync(projPath); } catch { continue; }
45
+ if (!stat.isDirectory()) continue;
46
+ for (const f of fs.readdirSync(projPath)) {
47
+ if (!f.endsWith('.jsonl')) continue;
48
+ const full = path.join(projPath, f);
49
+ let s;
50
+ try { s = fs.statSync(full); } catch { continue; }
51
+ if (!s.isFile()) continue;
52
+ out.push({
53
+ file: full,
54
+ projectDir: projDir,
55
+ cwd: decodeProjectDir(projDir),
56
+ mtime: s.mtime,
57
+ size: s.size,
58
+ });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ async function readJsonlLines(file) {
65
+ return new Promise((resolve, reject) => {
66
+ const lines = [];
67
+ const stream = fs.createReadStream(file, { encoding: 'utf8' });
68
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
69
+ rl.on('line', (l) => { if (l.trim()) lines.push(l); });
70
+ rl.on('close', () => resolve(lines));
71
+ rl.on('error', reject);
72
+ });
73
+ }
74
+
75
+ function parseLine(line) {
76
+ try { return JSON.parse(line); } catch { return null; }
77
+ }
78
+
79
+ async function readSessionEntries(file) {
80
+ const lines = await readJsonlLines(file);
81
+ const entries = [];
82
+ for (const l of lines) {
83
+ const e = parseLine(l);
84
+ if (e) entries.push(e);
85
+ }
86
+ return entries;
87
+ }
88
+
89
+ // Extract concrete agent spawns from a `subagent` toolCall arguments.
90
+ // Returns [] for management calls (action:"list" etc) so they don't pollute the agent list.
91
+ // Single spawn: {agent, task} -> [{agent, task}]
92
+ // Parallel: {tasks: [{agent, task}, ...]} -> one rec per task
93
+ // Chain: {chain: [step, ...]} where each step is either {agent, task} or {parallel: [{agent, task}, ...]}
94
+ function expandSubagentSpawns(args) {
95
+ if (!args || typeof args !== 'object') return [];
96
+ if (args.task && typeof args.task === 'string') {
97
+ return [{ agent: args.agent || null, task: args.task }];
98
+ }
99
+ if (Array.isArray(args.tasks)) {
100
+ const out = [];
101
+ for (const item of args.tasks) {
102
+ if (item && item.task) out.push({ agent: item.agent || null, task: item.task });
103
+ }
104
+ return out;
105
+ }
106
+ if (Array.isArray(args.chain)) {
107
+ const out = [];
108
+ for (const step of args.chain) {
109
+ if (!step || typeof step !== 'object') continue;
110
+ if (Array.isArray(step.parallel)) {
111
+ for (const item of step.parallel) {
112
+ if (item && item.task) out.push({ agent: item.agent || null, task: item.task });
113
+ }
114
+ } else if (step.task) {
115
+ out.push({ agent: step.agent || null, task: step.task });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ return [];
121
+ }
122
+
123
+ function summarize(entries) {
124
+ let sessionEntry = null;
125
+ let firstModel = null;
126
+ let lastTimestamp = null;
127
+ let totalCost = 0;
128
+ let totalInput = 0;
129
+ let totalOutput = 0;
130
+ let totalCacheRead = 0;
131
+ let totalCacheWrite = 0;
132
+ let userCount = 0;
133
+ let assistantCount = 0;
134
+ let toolCalls = 0;
135
+ let toolResults = 0;
136
+ let messageCount = 0;
137
+ let provider = null;
138
+ let model = null;
139
+ let customTitle = null;
140
+ let lastAssistantUsage = null;
141
+ let lastTodoTasks = null;
142
+ const subagents = [];
143
+ // toolCallId -> array of pending recs (chain mode produces one rec per child)
144
+ const subagentByToolCallId = new Map();
145
+ // `${runId}_${index}` -> rec, for /run slash-invoked subagents (custom_message)
146
+ const subagentByRunKey = new Map();
147
+
148
+ for (const e of entries) {
149
+ if (e.type === 'session') sessionEntry = e;
150
+ else if (e.type === 'session_info') {
151
+ if (typeof e.name === 'string' && e.name.trim()) customTitle = e.name.trim();
152
+ }
153
+ else if (e.type === 'model_change') {
154
+ if (!firstModel) firstModel = e;
155
+ provider = e.provider;
156
+ model = e.modelId;
157
+ } else if (e.type === 'message') {
158
+ messageCount++;
159
+ lastTimestamp = e.timestamp || lastTimestamp;
160
+ const m = e.message || {};
161
+ if (m.role === 'user') userCount++;
162
+ else if (m.role === 'assistant') {
163
+ assistantCount++;
164
+ const u = m.usage;
165
+ if (u) {
166
+ totalInput += u.input || 0;
167
+ totalOutput += u.output || 0;
168
+ totalCacheRead += u.cacheRead || 0;
169
+ totalCacheWrite += u.cacheWrite || 0;
170
+ totalCost += (u.cost && u.cost.total) || 0;
171
+ if ((u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0) + (u.output || 0) > 0) {
172
+ lastAssistantUsage = u;
173
+ }
174
+ }
175
+ if (m.provider) provider = m.provider;
176
+ if (m.model) model = m.model;
177
+ if (Array.isArray(m.content)) {
178
+ for (const c of m.content) {
179
+ if (c && c.type === 'toolCall') {
180
+ toolCalls++;
181
+ if (c.name === 'subagent') {
182
+ const items = expandSubagentSpawns(c.arguments);
183
+ if (items.length) {
184
+ const recs = items.map((it, i) => ({
185
+ toolCallId: c.id || null,
186
+ agent: it.agent || null,
187
+ prompt: it.task || null,
188
+ startedAt: e.timestamp || null,
189
+ runId: null,
190
+ exitCode: null,
191
+ durationMs: null,
192
+ usage: null,
193
+ progressSummary: null,
194
+ sessionFile: null,
195
+ artifactPaths: null,
196
+ stoppedAt: null,
197
+ runIndex: i,
198
+ }));
199
+ for (const rec of recs) subagents.push(rec);
200
+ if (c.id) subagentByToolCallId.set(c.id, recs);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ } else if (m.role === 'toolResult') {
207
+ toolResults++;
208
+ if (m.toolName === 'todo' && m.details && Array.isArray(m.details.tasks)) {
209
+ lastTodoTasks = m.details.tasks;
210
+ } else if (m.toolName === 'subagent' && m.details && Array.isArray(m.details.results)) {
211
+ const pendingArr = m.toolCallId ? subagentByToolCallId.get(m.toolCallId) : null;
212
+ m.details.results.forEach((r, idx) => {
213
+ let target = pendingArr && pendingArr[idx];
214
+ if (!target) {
215
+ const base = pendingArr && pendingArr[0];
216
+ target = base ? { ...base, runIndex: idx } : {
217
+ toolCallId: m.toolCallId || null,
218
+ agent: null,
219
+ prompt: null,
220
+ startedAt: null,
221
+ runIndex: idx,
222
+ };
223
+ subagents.push(target);
224
+ }
225
+ target.runId = m.details.runId || target.runId;
226
+ target.runIndex = idx;
227
+ target.agent = r.agent || target.agent;
228
+ target.prompt = target.prompt || r.task || null;
229
+ target.exitCode = r.exitCode ?? null;
230
+ target.durationMs = r.durationMs ?? null;
231
+ target.usage = r.usage || null;
232
+ target.progressSummary = r.progressSummary || null;
233
+ target.sessionFile = r.sessionFile || null;
234
+ target.artifactPaths = r.artifactPaths || null;
235
+ target.stoppedAt = e.timestamp || null;
236
+ });
237
+ if (m.details.results.length === 0 && pendingArr && pendingArr.length) {
238
+ const text = Array.isArray(m.content) ? (m.content.find((c) => c && c.type === 'text')?.text || '') : '';
239
+ const cancelled = /cancel/i.test(text);
240
+ for (const rec of pendingArr) {
241
+ rec.stoppedAt = e.timestamp || null;
242
+ if (cancelled) rec._error = rec._error || 'Chain cancelled';
243
+ }
244
+ }
245
+ }
246
+ }
247
+ } else if (e.type === 'custom_message' && e.customType === 'subagent-slash-result') {
248
+ lastTimestamp = e.timestamp || lastTimestamp;
249
+ const det = e.details && e.details.result && e.details.result.details;
250
+ const results = det && Array.isArray(det.results) ? det.results : null;
251
+ if (results) {
252
+ const runId = det.runId || null;
253
+ const requestId = (e.details && e.details.requestId) || null;
254
+ // Progress records lack runId; key by (requestId, agent, idx) so the later
255
+ // final record (same requestId) upserts the same target.
256
+ results.forEach((r, idx) => {
257
+ const key = `${requestId || runId || 'noid'}_${r.agent || ''}_${idx}`;
258
+ let target = subagentByRunKey.get(key);
259
+ if (!target) {
260
+ target = {
261
+ toolCallId: null,
262
+ agent: r.agent || null,
263
+ prompt: r.task || null,
264
+ startedAt: e.timestamp || null,
265
+ runId,
266
+ runIndex: idx,
267
+ exitCode: null,
268
+ durationMs: null,
269
+ usage: null,
270
+ progressSummary: null,
271
+ sessionFile: null,
272
+ artifactPaths: null,
273
+ stoppedAt: null,
274
+ model: null,
275
+ };
276
+ subagents.push(target);
277
+ subagentByRunKey.set(key, target);
278
+ }
279
+ if (runId) target.runId = runId;
280
+ if (r.agent) target.agent = r.agent;
281
+ if (!target.prompt && r.task) target.prompt = r.task;
282
+ if (r.exitCode !== undefined) target.exitCode = r.exitCode;
283
+ if (r.durationMs !== undefined) target.durationMs = r.durationMs;
284
+ if (r.usage) target.usage = r.usage;
285
+ if (r.progressSummary) target.progressSummary = r.progressSummary;
286
+ if (r.sessionFile) target.sessionFile = r.sessionFile;
287
+ if (r.artifactPaths) target.artifactPaths = r.artifactPaths;
288
+ if (r.model) target.model = r.model;
289
+ if (r.error) target._error = r.error;
290
+ // pi emits progress events without these fields; their presence marks completion.
291
+ const isFinal = !!(r.model || r.artifactPaths || r.sessionFile || r.error);
292
+ if (isFinal) target.stoppedAt = e.timestamp || target.stoppedAt;
293
+ });
294
+ }
295
+ }
296
+ }
297
+
298
+ return {
299
+ sessionEntry,
300
+ firstModel,
301
+ lastTimestamp,
302
+ totalCost,
303
+ totalInput,
304
+ totalOutput,
305
+ totalCacheRead,
306
+ totalCacheWrite,
307
+ totalTokens: totalInput + totalOutput,
308
+ userCount,
309
+ assistantCount,
310
+ toolCalls,
311
+ toolResults,
312
+ messageCount,
313
+ provider,
314
+ model,
315
+ customTitle,
316
+ lastAssistantUsage,
317
+ lastTodoTasks,
318
+ subagents,
319
+ };
320
+ }
321
+
322
+ function agentIdFor(rec) {
323
+ if (rec.toolCallId) return `tc_${rec.toolCallId}_${rec.runIndex ?? 0}`;
324
+ if (rec.runId && rec.agent) return `${rec.runId}_${rec.agent}_${rec.runIndex ?? 0}`;
325
+ if (rec.runId) return rec.runId;
326
+ return `pending_${Math.random().toString(36).slice(2, 10)}`;
327
+ }
328
+
329
+ function subagentToApi(rec, lastMessage) {
330
+ const stopped = !!rec.stoppedAt;
331
+ let desc = rec.progressSummary
332
+ ? `${rec.progressSummary.toolCount ?? 0} tools · ${rec.progressSummary.tokens ?? 0} tokens`
333
+ : null;
334
+ if (rec._error) desc = `error · ${String(rec._error).split('\n')[0].slice(0, 80)}`;
335
+ return {
336
+ agentId: agentIdFor(rec),
337
+ type: rec.agent || null,
338
+ agentName: rec.agent || null,
339
+ status: stopped ? 'stopped' : 'active',
340
+ startedAt: rec.startedAt || null,
341
+ stoppedAt: rec.stoppedAt || null,
342
+ updatedAt: rec.stoppedAt || rec.startedAt || null,
343
+ prompt: rec.prompt || null,
344
+ description: desc,
345
+ model: rec.model || null,
346
+ color: null,
347
+ lastMessage: lastMessage || null,
348
+ _sessionFile: rec.sessionFile || null,
349
+ _exitCode: rec.exitCode ?? null,
350
+ _durationMs: rec.durationMs ?? null,
351
+ _usage: rec.usage || null,
352
+ };
353
+ }
354
+
355
+ async function readSubagentOutput(rec) {
356
+ if (!rec || !rec.artifactPaths || !rec.artifactPaths.outputPath) return null;
357
+ try { return await fs.promises.readFile(rec.artifactPaths.outputPath, 'utf8'); } catch { return null; }
358
+ }
359
+
360
+ async function enrichPendingSubagents(meta, subagents) {
361
+ let pending = subagents.filter((r) => !r.runId);
362
+ if (pending.length === 0) return;
363
+ const artifactsDir = path.join(path.dirname(meta.file), 'subagent-artifacts');
364
+ let files = null;
365
+ try { files = await fs.promises.readdir(artifactsDir); } catch {}
366
+ const knownRunIds = new Set(subagents.map((r) => r.runId).filter(Boolean));
367
+ const metas = [];
368
+ if (files) {
369
+ const metaFiles = files.filter((f) => f.endsWith('_meta.json'));
370
+ for (const f of metaFiles) {
371
+ try {
372
+ const data = JSON.parse(await fs.promises.readFile(path.join(artifactsDir, f), 'utf8'));
373
+ if (data && data.runId && !knownRunIds.has(data.runId)) metas.push({ ...data, _file: f });
374
+ } catch {}
375
+ }
376
+ }
377
+ metas.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
378
+ const used = new Set();
379
+ for (const rec of pending) {
380
+ const startMs = rec.startedAt ? new Date(rec.startedAt).getTime() : 0;
381
+ const idx = metas.findIndex((m, i) => {
382
+ if (used.has(i)) return false;
383
+ if (m.agent !== rec.agent) return false;
384
+ if (rec.prompt && m.task && !m.task.includes(rec.prompt.slice(0, 40))) return false;
385
+ if (startMs && m.timestamp && m.timestamp < startMs - 1000) return false;
386
+ return true;
387
+ });
388
+ if (idx < 0) continue;
389
+ used.add(idx);
390
+ const m = metas[idx];
391
+ const base = m._file.replace(/_meta\.json$/, '');
392
+ rec.runId = m.runId;
393
+ rec.runIndex = 0;
394
+ rec.exitCode = m.exitCode;
395
+ rec.durationMs = m.durationMs;
396
+ rec.usage = m.usage;
397
+ rec.stoppedAt = m.timestamp ? new Date(m.timestamp).toISOString() : (rec.startedAt || null);
398
+ rec.artifactPaths = {
399
+ inputPath: path.join(artifactsDir, `${base}_input.md`),
400
+ outputPath: path.join(artifactsDir, `${base}_output.md`),
401
+ jsonlPath: path.join(artifactsDir, `${base}.jsonl`),
402
+ metadataPath: path.join(artifactsDir, m._file),
403
+ };
404
+ rec.sessionFile = rec.artifactPaths.jsonlPath;
405
+ rec._error = m.error || null;
406
+ if (m.toolCount != null) rec.progressSummary = { toolCount: m.toolCount, tokens: (m.usage?.input || 0) + (m.usage?.output || 0) };
407
+ }
408
+
409
+ pending = subagents.filter((r) => !r.runId);
410
+ if (pending.length === 0) return;
411
+ await matchLiveSubagentRuns(meta, pending, subagents);
412
+ }
413
+
414
+ async function matchLiveSubagentRuns(meta, pending, allSubagents) {
415
+ const sessionDir = meta.file.replace(/\.jsonl$/, '');
416
+ let entries = [];
417
+ try { entries = await fs.promises.readdir(sessionDir, { withFileTypes: true }); } catch {}
418
+ const knownRunIds = new Set(allSubagents.map((r) => r.runId).filter(Boolean));
419
+ const liveRuns = [];
420
+ for (const d of entries) {
421
+ if (!d.isDirectory()) continue;
422
+ if (d.name === 'subagent-artifacts') continue;
423
+ if (knownRunIds.has(d.name)) continue;
424
+ const runDirPath = path.join(sessionDir, d.name);
425
+ let runs;
426
+ try { runs = await fs.promises.readdir(runDirPath); } catch { continue; }
427
+ for (const r of runs) {
428
+ const m = /^run-(\d+)$/.exec(r);
429
+ if (!m) continue;
430
+ const jsonl = path.join(runDirPath, r, 'session.jsonl');
431
+ try {
432
+ const stat = await fs.promises.stat(jsonl);
433
+ const raw = await fs.promises.readFile(jsonl, 'utf8');
434
+ const lines = raw.split('\n').filter(Boolean).slice(0, 10);
435
+ let firstTs = null;
436
+ let firstUserText = null;
437
+ for (const line of lines) {
438
+ try {
439
+ const ev = JSON.parse(line);
440
+ if (ev.type === 'session' && !firstTs) firstTs = ev.timestamp;
441
+ if (!firstUserText && ev.type === 'message' && ev.message && ev.message.role === 'user' && Array.isArray(ev.message.content)) {
442
+ const txt = ev.message.content.find((c) => c && c.type === 'text');
443
+ if (txt && typeof txt.text === 'string') firstUserText = txt.text;
444
+ }
445
+ } catch {}
446
+ }
447
+ liveRuns.push({ runId: d.name, runIndex: parseInt(m[1], 10), jsonl, firstTs, firstUserText, mtime: stat.mtimeMs });
448
+ } catch {}
449
+ }
450
+ }
451
+ liveRuns.sort((a, b) => (a.firstTs ? new Date(a.firstTs).getTime() : 0) - (b.firstTs ? new Date(b.firstTs).getTime() : 0));
452
+ const used = new Set();
453
+ for (const rec of (liveRuns.length ? pending : [])) {
454
+ const startMs = rec.startedAt ? new Date(rec.startedAt).getTime() : 0;
455
+ const idx = liveRuns.findIndex((lr, i) => {
456
+ if (used.has(i)) return false;
457
+ const runMs = lr.firstTs ? new Date(lr.firstTs).getTime() : 0;
458
+ if (startMs && runMs && runMs < startMs - 5000) return false;
459
+ if (rec.prompt && lr.firstUserText) {
460
+ const probe = rec.prompt.slice(0, 60);
461
+ if (probe && !lr.firstUserText.includes(probe)) return false;
462
+ }
463
+ return true;
464
+ });
465
+ if (idx < 0) continue;
466
+ used.add(idx);
467
+ const lr = liveRuns[idx];
468
+ rec.runId = lr.runId;
469
+ rec.runIndex = lr.runIndex;
470
+ rec.sessionFile = lr.jsonl;
471
+ }
472
+
473
+ let parentMtime = 0;
474
+ try { parentMtime = (await fs.promises.stat(meta.file)).mtimeMs; } catch {}
475
+ const STALE_MS = 60_000;
476
+ const now = Date.now();
477
+ const parentStale = parentMtime && (now - parentMtime > STALE_MS);
478
+ for (const rec of pending) {
479
+ if (rec.stoppedAt) continue;
480
+ let runMtime = 0;
481
+ if (rec.sessionFile) {
482
+ try { runMtime = (await fs.promises.stat(rec.sessionFile)).mtimeMs; } catch {}
483
+ }
484
+ const runStale = runMtime ? (now - runMtime > STALE_MS) : true;
485
+ if (parentStale && runStale) {
486
+ rec.stoppedAt = new Date(runMtime || parentMtime || now).toISOString();
487
+ rec._error = rec._error || 'Interrupted (no result recorded)';
488
+ }
489
+ }
490
+ }
491
+
492
+ async function listAgentsForSession(meta) {
493
+ const entries = await readSessionEntries(meta.file);
494
+ const s = summarize(entries);
495
+ const recs = s.subagents || [];
496
+ await enrichPendingSubagents(meta, recs);
497
+ const out = [];
498
+ for (const rec of recs) {
499
+ let lm = await readSubagentOutput(rec);
500
+ if ((!lm || !lm.trim()) && rec._error) lm = `**Error:**\n\n\`\`\`\n${rec._error}\n\`\`\``;
501
+ out.push(subagentToApi(rec, lm));
502
+ }
503
+ return out;
504
+ }
505
+
506
+ async function findAgentRecord(meta, agentId) {
507
+ const list = await listAgentsForSession(meta);
508
+ return list.find((a) => a.agentId === agentId) || null;
509
+ }
510
+
511
+ function tallyTasks(tasks) {
512
+ let pending = 0, inProgress = 0, completed = 0;
513
+ for (const t of tasks) {
514
+ if (t.status === 'completed') completed++;
515
+ else if (t.status === 'in_progress') inProgress++;
516
+ else pending++;
517
+ }
518
+ return { taskCount: tasks.length, pending, inProgress, completed };
519
+ }
520
+
521
+ const MODEL_CONTEXT_WINDOWS = [
522
+ [/claude/i, 200000],
523
+ [/kimi/i, 256000],
524
+ [/glm-?4\.[67]/i, 128000],
525
+ [/gpt-?5/i, 400000],
526
+ [/gpt-?4/i, 128000],
527
+ [/gemini/i, 1000000],
528
+ [/deepseek/i, 128000],
529
+ ];
530
+ function modelContextWindow(model) {
531
+ if (!model) return 200000;
532
+ const m = String(model);
533
+ for (const [re, cap] of MODEL_CONTEXT_WINDOWS) if (re.test(m)) return cap;
534
+ return 200000;
535
+ }
536
+
537
+ function shortIdFromUuid(uuid) {
538
+ if (!uuid) return '';
539
+ return uuid.split('-')[0];
540
+ }
541
+
542
+ function basenameWithoutExt(file) {
543
+ return path.basename(file, '.jsonl');
544
+ }
545
+
546
+ // Build a slug like "<short-uuid>" from filename.
547
+ function slugFromFile(file) {
548
+ const base = basenameWithoutExt(file);
549
+ // 2026-05-07T08-49-06-833Z_019e01a0-5b0d-7139-9740-bc883238364d
550
+ const idx = base.indexOf('_');
551
+ if (idx >= 0) return base.slice(idx + 1);
552
+ return base;
553
+ }
554
+
555
+ const RECENT_MS = 5 * 60 * 1000;
556
+
557
+ async function buildSessionSummary(meta) {
558
+ const entries = await readSessionEntries(meta.file);
559
+ const s = summarize(entries);
560
+ const id = (s.sessionEntry && s.sessionEntry.id) || slugFromFile(meta.file);
561
+ const cwd = (s.sessionEntry && s.sessionEntry.cwd) || meta.cwd;
562
+ const projectName = path.basename(cwd) || cwd;
563
+ const ageMs = Date.now() - meta.mtime.getTime();
564
+ const isRecent = ageMs <= RECENT_MS;
565
+ return {
566
+ id,
567
+ name: s.customTitle || shortIdFromUuid(id),
568
+ slug: shortIdFromUuid(id),
569
+ project: cwd,
570
+ cwd,
571
+ description: null,
572
+ gitBranch: gitBranchModule.getBranch(cwd, (branch) => {
573
+ if (onBranchResolved) onBranchResolved(cwd, branch);
574
+ }),
575
+ customTitle: s.customTitle || null,
576
+ parentSession: (s.sessionEntry && s.sessionEntry.parentSession) || null,
577
+ ...tallyTasks(taskStore.listTasks(id)),
578
+ createdAt: s.sessionEntry && s.sessionEntry.timestamp,
579
+ modifiedAt: meta.mtime.toISOString(),
580
+ hasMessages: s.messageCount > 0,
581
+ hasActiveAgents: false,
582
+ hasRunningAgents: false,
583
+ hasWaitingForUser: false,
584
+ hasRecentLog: isRecent,
585
+ jsonlPath: meta.file,
586
+ tasksDir: null,
587
+ projectDir: meta.projectDir,
588
+ contextStatus: {
589
+ session_id: id,
590
+ transcript_path: meta.file,
591
+ cwd,
592
+ session_name: projectName,
593
+ model: { id: s.model || 'unknown', display_name: s.model || 'unknown' },
594
+ provider: s.provider,
595
+ workspace: { current_dir: cwd, project_dir: cwd, added_dirs: [cwd] },
596
+ version: s.sessionEntry && s.sessionEntry.version,
597
+ cost: {
598
+ total_cost_usd: s.totalCost,
599
+ total_duration_ms: 0,
600
+ total_api_duration_ms: 0,
601
+ total_lines_added: 0,
602
+ total_lines_removed: 0,
603
+ },
604
+ context_window: (() => {
605
+ const lu = s.lastAssistantUsage || {};
606
+ const cap = modelContextWindow(s.model);
607
+ const curIn = (lu.input || 0) + (lu.cacheRead || 0) + (lu.cacheWrite || 0);
608
+ const curOut = lu.output || 0;
609
+ const used = Math.min(100, Math.round((curIn / cap) * 100));
610
+ return {
611
+ total_input_tokens: s.totalInput,
612
+ total_output_tokens: s.totalOutput,
613
+ context_window_size: cap,
614
+ current_usage: {
615
+ input_tokens: curIn,
616
+ output_tokens: curOut,
617
+ cache_creation_input_tokens: lu.cacheWrite || 0,
618
+ cache_read_input_tokens: lu.cacheRead || 0,
619
+ },
620
+ used_percentage: used,
621
+ remaining_percentage: Math.max(0, 100 - used),
622
+ };
623
+ })(),
624
+ _stats: {
625
+ userCount: s.userCount,
626
+ assistantCount: s.assistantCount,
627
+ toolCalls: s.toolCalls,
628
+ toolResults: s.toolResults,
629
+ messageCount: s.messageCount,
630
+ },
631
+ _updatedAt: meta.mtime.getTime(),
632
+ },
633
+ hasPlan: false,
634
+ planTitle: null,
635
+ planPath: null,
636
+ };
637
+ }
638
+
639
+ async function listSessions() {
640
+ const files = listSessionFiles();
641
+ files.sort((a, b) => b.mtime - a.mtime);
642
+ const out = [];
643
+ const seen = new Set();
644
+ for (const f of files) {
645
+ try {
646
+ const summary = await buildSessionSummary(f);
647
+ if (summary.parentSession) continue;
648
+ if (seen.has(summary.id)) continue;
649
+ seen.add(summary.id);
650
+ out.push(summary);
651
+ } catch (err) {
652
+ // skip corrupt files
653
+ }
654
+ }
655
+ return out;
656
+ }
657
+
658
+ async function findSessionFileById(id) {
659
+ const files = listSessionFiles();
660
+ files.sort((a, b) => b.mtime - a.mtime);
661
+ // First try filename match (faster)
662
+ for (const f of files) {
663
+ if (f.file.includes(id)) return f;
664
+ }
665
+ // Then read header to match session id
666
+ for (const f of files) {
667
+ try {
668
+ const entries = await readSessionEntries(f.file);
669
+ const sess = entries.find((e) => e.type === 'session');
670
+ if (sess && sess.id === id) return f;
671
+ } catch {}
672
+ }
673
+ return null;
674
+ }
675
+
676
+ function flattenContentToText(content, opts = {}) {
677
+ const { includeThinking = false } = opts;
678
+ if (typeof content === 'string') return content;
679
+ if (!Array.isArray(content)) return '';
680
+ return content
681
+ .map((c) => {
682
+ if (!c) return '';
683
+ if (c.type === 'text') return c.text || '';
684
+ if (includeThinking && c.type === 'thinking') return c.thinking || '';
685
+ return '';
686
+ })
687
+ .filter(Boolean)
688
+ .join('\n\n');
689
+ }
690
+
691
+ // Map pi tool name -> display name (matches cck conventions).
692
+ const TOOL_NAME_MAP = {
693
+ read: 'Read',
694
+ write: 'Write',
695
+ edit: 'Edit',
696
+ bash: 'Bash',
697
+ todo: 'TodoWrite',
698
+ subagent: 'Agent',
699
+ ask_user: 'AskUser',
700
+ ask_user_question: 'AskUserQuestion',
701
+ web_search: 'WebSearch',
702
+ code_search: 'Grep',
703
+ ast_grep_search: 'AstGrep',
704
+ lsp: 'LSP',
705
+ lsp_navigation: 'LSP',
706
+ interactive_shell: 'Shell',
707
+ mark_step_done: 'MarkStepDone',
708
+ };
709
+
710
+ function computeToolDetail(name, params) {
711
+ if (!params || typeof params !== 'object') {
712
+ const s = String(params ?? '');
713
+ return { detail: s.slice(0, 80), fullDetail: s.length > 80 ? s : null };
714
+ }
715
+ let detail = null;
716
+ let full = null;
717
+ if (params.file_path) { detail = path.basename(params.file_path); full = params.file_path; }
718
+ else if (params.path) { detail = path.basename(params.path); full = params.path; }
719
+ else if (params.command) { full = params.command; detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
720
+ else if (params.pattern) { full = params.pattern; detail = full; }
721
+ else if (params.query) { full = params.query; detail = full; }
722
+ else if (params.queries) { full = Array.isArray(params.queries) ? params.queries.join(' | ') : String(params.queries); detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
723
+ else if (params.url) { full = params.url; detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
724
+ else if (params.task) { full = params.task; detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
725
+ else if (params.subject) { full = params.subject; detail = full; }
726
+ else if (params.description) { full = params.description; detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
727
+ else if (params.question) { full = params.question; detail = full.length > 80 ? full.slice(0, 80) + '...' : full; }
728
+ else if (params.action) { detail = params.action; full = detail; }
729
+ else if (params.operation) { detail = params.operation; full = detail; }
730
+ else {
731
+ try { full = JSON.stringify(params); } catch { full = ''; }
732
+ detail = full.length > 80 ? full.slice(0, 80) + '...' : full;
733
+ }
734
+ return { detail: detail || '', fullDetail: full && full !== detail ? full : null };
735
+ }
736
+
737
+ function normalizeToolCall(name, args) {
738
+ const display = TOOL_NAME_MAP[name] || name;
739
+ if (!args || typeof args !== 'object') return { name: display, params: args };
740
+ const p = { ...args };
741
+ if (p.path && !p.file_path) {
742
+ p.file_path = p.path;
743
+ delete p.path;
744
+ }
745
+ if (Array.isArray(p.edits)) {
746
+ if (p.edits.length === 1) {
747
+ const e = p.edits[0];
748
+ if (e && (e.oldText || e.newText)) {
749
+ p.old_string = e.oldText || '';
750
+ p.new_string = e.newText || '';
751
+ delete p.edits;
752
+ }
753
+ } else {
754
+ p.edits = p.edits.map((e) => ({
755
+ old_string: e.oldText ?? e.old_string ?? '',
756
+ new_string: e.newText ?? e.new_string ?? '',
757
+ }));
758
+ }
759
+ }
760
+ return { name: display, params: p };
761
+ }
762
+
763
+ // Convert pi entries -> cck-style messages array.
764
+ async function readFileSafe(p) {
765
+ if (!p) return null;
766
+ try { return await fs.promises.readFile(p, 'utf8'); } catch { return null; }
767
+ }
768
+
769
+ async function buildMessages(file, limit = 50, before = null) {
770
+ const entries = await readSessionEntries(file);
771
+ const toolResultByCallId = new Map();
772
+ const subagentInfoByCallId = new Map();
773
+ for (const e of entries) {
774
+ if (e.type !== 'message') continue;
775
+ const m = e.message;
776
+ if (m && m.role === 'toolResult' && m.toolCallId) {
777
+ toolResultByCallId.set(m.toolCallId, m);
778
+ if (m.toolName === 'subagent' && m.details && Array.isArray(m.details.results) && m.details.results[0]) {
779
+ const r = m.details.results[0];
780
+ subagentInfoByCallId.set(m.toolCallId, {
781
+ runId: m.details.runId || null,
782
+ agent: r.agent || null,
783
+ runIndex: 0,
784
+ outputPath: r.artifactPaths && r.artifactPaths.outputPath ? r.artifactPaths.outputPath : null,
785
+ inputPath: r.artifactPaths && r.artifactPaths.inputPath ? r.artifactPaths.inputPath : null,
786
+ });
787
+ }
788
+ }
789
+ }
790
+
791
+ const messages = [];
792
+ for (const e of entries) {
793
+ if (e.type === 'compaction') {
794
+ messages.push({
795
+ type: 'user',
796
+ role: 'user',
797
+ systemLabel: 'compact-summary',
798
+ compactSummary: e.summary || '',
799
+ text: e.summary || '',
800
+ timestamp: e.timestamp,
801
+ });
802
+ continue;
803
+ }
804
+ if (e.type !== 'message') continue;
805
+ const m = e.message || {};
806
+ const ts = e.timestamp;
807
+
808
+ if (m.role === 'user') {
809
+ const text = flattenContentToText(m.content);
810
+ messages.push({ type: 'user', role: 'user', text, timestamp: ts });
811
+ } else if (m.role === 'assistant') {
812
+ const text = flattenContentToText(m.content);
813
+ if (text && text.trim()) {
814
+ messages.push({
815
+ type: 'assistant',
816
+ role: 'assistant',
817
+ text,
818
+ timestamp: ts,
819
+ model: m.model,
820
+ provider: m.provider,
821
+ usage: m.usage,
822
+ });
823
+ }
824
+ if (Array.isArray(m.content)) {
825
+ for (const c of m.content) {
826
+ if (c && c.type === 'toolCall') {
827
+ const { name: toolName, params } = normalizeToolCall(c.name, c.arguments);
828
+ const { detail, fullDetail } = computeToolDetail(toolName, params);
829
+ const tr = toolResultByCallId.get(c.id);
830
+ const trText = tr ? flattenContentToText(tr.content) : null;
831
+ const msg = {
832
+ type: 'tool_use',
833
+ tool: toolName,
834
+ detail,
835
+ fullDetail,
836
+ description: null,
837
+ params,
838
+ timestamp: ts,
839
+ toolUseId: c.id,
840
+ toolResult: trText ? trText.slice(0, 1500) : null,
841
+ toolResultTruncated: trText ? trText.length > 1500 : false,
842
+ };
843
+ if (c.name === 'subagent') {
844
+ const info = subagentInfoByCallId.get(c.id);
845
+ const agent = (c.arguments && c.arguments.agent) || (info && info.agent) || null;
846
+ const runId = info && info.runId ? info.runId : null;
847
+ const isSpawn = !!(c.arguments && c.arguments.task);
848
+ if (isSpawn) {
849
+ msg.tool = 'Agent';
850
+ msg.agentId = c.id ? `tc_${c.id}_${(info && info.runIndex) || 0}` : (runId && agent ? `${runId}_${agent}_${(info && info.runIndex) || 0}` : `pending_${Math.random().toString(36).slice(2, 10)}`);
851
+ msg.agentType = agent;
852
+ msg.agentName = agent;
853
+ msg.agentPrompt = c.arguments.task || null;
854
+ msg.agentLastMessage = info && info.outputPath ? await readFileSafe(info.outputPath) : null;
855
+ if (!msg.agentLastMessage && trText) msg.agentLastMessage = trText;
856
+ }
857
+ }
858
+ messages.push(msg);
859
+ }
860
+ }
861
+ }
862
+ }
863
+ // toolResult handled via map above.
864
+ }
865
+
866
+ const filtered = before
867
+ ? messages.filter((m) => m.timestamp && m.timestamp < before)
868
+ : messages;
869
+ const total = filtered.length;
870
+ const sliced = limit && limit < total ? filtered.slice(-limit) : filtered;
871
+ return { messages: sliced, hasMore: sliced.length < total, sessionId: null };
872
+ }
873
+
874
+ module.exports = {
875
+ getPiDir,
876
+ getSessionsDir,
877
+ decodeProjectDir,
878
+ listSessionFiles,
879
+ listSessions,
880
+ findSessionFileById,
881
+ buildSessionSummary,
882
+ buildMessages,
883
+ readSessionEntries,
884
+ summarize,
885
+ listAgentsForSession,
886
+ findAgentRecord,
887
+ slugFromFile,
888
+ setOnBranchResolved,
889
+ };