pi-kanban 0.1.3 → 0.3.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.
package/README.md CHANGED
@@ -2,10 +2,14 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-kanban.svg)](https://www.npmjs.com/package/pi-kanban)
4
4
 
5
- Web dashboard for the [pi coding agent](https://pi.dev) — sessions, todos, subagents, observability.
5
+ Web dashboard for the [pi coding agent](https://pi.dev/packages/pi-kanban?name=kanban) — sessions, todos, subagents, observability.
6
6
 
7
7
  ![pi-kanban dashboard](https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-kanban-poster.png)
8
8
 
9
+ ## Documentation
10
+
11
+ **[→ See the documentation](https://nikiforovall.github.io/pi-kanban/docs/)**
12
+
9
13
  ## Try it now
10
14
 
11
15
  **[→ Open the live demo](https://nikiforovall.blog/pi-kanban/)**
@@ -39,8 +43,9 @@ pi install npm:@juicesharp/rpiv-todo
39
43
 
40
44
  - [`pi-subagents`](https://www.npmjs.com/package/pi-subagents) — spawns named subagent sessions; pi-kanban renders them under each parent session.
41
45
  - [`@juicesharp/rpiv-todo`](https://www.npmjs.com/package/@juicesharp/rpiv-todo) — task tracking that pi-kanban displays as kanban columns.
46
+ - [`@juicesharp/rpiv-ask-user-question`](https://www.npmjs.com/package/@juicesharp/rpiv-ask-user-question) — pi-kanban surfaces these as interactive Q&A cards.
47
+
48
+ ## Demo
42
49
 
43
- ## Documentation
44
50
 
45
- - [User guide](https://github.com/NikiforovAll/pi-kanban/blob/main/docs/user-guide.md) — slash commands, layout, sessions, subagents, storage manager, keyboard shortcuts.
46
- - [Theming](https://github.com/NikiforovAll/pi-kanban/blob/main/docs/theming.md) — built-in themes, configuration, custom theme authoring.
51
+ ![pi-kanban demo](https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-kanban-demo.gif)
@@ -1,10 +1,11 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { createConnection } from "node:net";
5
5
  import { homedir } from "node:os";
6
6
  import { isAbsolute, join as joinPath, resolve as resolvePath } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { Type } from "typebox";
8
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
10
 
10
11
  const extDir = fileURLToPath(new URL(".", import.meta.url));
@@ -121,6 +122,21 @@ async function postPreview(filePath: string, sessionId: string | null, link = fa
121
122
  });
122
123
  }
123
124
 
125
+ async function postPlan(id: string, planPath: string, title: string | null): Promise<Response> {
126
+ return api("/api/session/plan", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ id, path: planPath, title }),
130
+ });
131
+ }
132
+
133
+ const PLAN_MAX_BYTES = 256 * 1024;
134
+
135
+ function extractFirstHeading(markdown: string): string | null {
136
+ const m = markdown.match(/^#\s+(.+?)\s*$/m);
137
+ return m ? m[1].trim() : null;
138
+ }
139
+
124
140
  function splitArgs(args: string): string[] {
125
141
  return args.trim().split(/\s+/).filter(Boolean);
126
142
  }
@@ -195,6 +211,73 @@ export default function kanbanExtension(pi: ExtensionAPI) {
195
211
  }
196
212
  });
197
213
 
214
+ pi.registerTool({
215
+ name: "bind_plan",
216
+ label: "Bind Plan",
217
+ description:
218
+ "Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
219
+ promptSnippet:
220
+ "Bind a markdown plan file to the current session — call after writing plan file",
221
+ promptGuidelines: [
222
+ "Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
223
+ ],
224
+ parameters: Type.Object({
225
+ path: Type.String({
226
+ description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
227
+ }),
228
+ }),
229
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
230
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
231
+ const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
232
+ const inputPath = String(params.path ?? "").trim();
233
+ if (!inputPath) {
234
+ return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
235
+ }
236
+ const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
237
+
238
+ let title: string | null = null;
239
+ try {
240
+ const stat = statSync(absPath);
241
+ if (!stat.isFile()) {
242
+ return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
243
+ }
244
+ const buf = readFileSync(absPath, { encoding: "utf8" });
245
+ const head = buf.slice(0, PLAN_MAX_BYTES);
246
+ title = extractFirstHeading(head);
247
+ } catch (e: any) {
248
+ return {
249
+ content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
250
+ isError: true,
251
+ };
252
+ }
253
+
254
+ if (!sessionId) {
255
+ return {
256
+ content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
257
+ isError: true,
258
+ };
259
+ }
260
+ try {
261
+ const res = await postPlan(sessionId, absPath, title);
262
+ if (!res.ok) {
263
+ const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
264
+ console.warn(msg);
265
+ return { content: [{ type: "text", text: msg }], isError: true };
266
+ }
267
+ } catch (e: any) {
268
+ const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
269
+ console.warn(msg);
270
+ return { content: [{ type: "text", text: msg }], isError: true };
271
+ }
272
+
273
+ const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
274
+ return {
275
+ content: [{ type: "text", text: summary }],
276
+ details: { path: absPath, title, sessionId },
277
+ };
278
+ },
279
+ });
280
+
198
281
  type Notify = (m: string, l?: "info" | "error") => void;
199
282
 
200
283
  async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
package/lib/pi-parsers.js CHANGED
@@ -35,30 +35,28 @@ function decodeProjectDir(name) {
35
35
  return s.replace(/-/g, path.sep);
36
36
  }
37
37
 
38
- function listSessionFiles() {
39
- if (!fs.existsSync(SESSIONS_DIR)) return [];
40
- const out = [];
41
- for (const projDir of fs.readdirSync(SESSIONS_DIR)) {
38
+ async function listSessionFiles() {
39
+ let projDirs;
40
+ try { projDirs = await fs.promises.readdir(SESSIONS_DIR, { withFileTypes: true }); }
41
+ catch { return []; }
42
+ const projects = projDirs.filter((d) => d.isDirectory());
43
+ const perProject = await Promise.all(projects.map(async (d) => {
44
+ const projDir = d.name;
42
45
  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;
46
+ let files;
47
+ try { files = await fs.promises.readdir(projPath, { withFileTypes: true }); }
48
+ catch { return []; }
49
+ const jsonls = files.filter((f) => f.isFile() && f.name.endsWith('.jsonl'));
50
+ const stats = await Promise.all(jsonls.map(async (f) => {
51
+ const full = path.join(projPath, f.name);
52
+ try {
53
+ const s = await fs.promises.stat(full);
54
+ return { file: full, projectDir: projDir, cwd: decodeProjectDir(projDir), mtime: s.mtime, mtimeMs: s.mtimeMs, size: s.size };
55
+ } catch { return null; }
56
+ }));
57
+ return stats.filter(Boolean);
58
+ }));
59
+ return perProject.flat();
62
60
  }
63
61
 
64
62
  async function readJsonlLines(file) {
@@ -76,7 +74,7 @@ function parseLine(line) {
76
74
  try { return JSON.parse(line); } catch { return null; }
77
75
  }
78
76
 
79
- async function readSessionEntries(file) {
77
+ async function readSessionEntriesUncached(file) {
80
78
  const lines = await readJsonlLines(file);
81
79
  const entries = [];
82
80
  for (const l of lines) {
@@ -86,6 +84,57 @@ async function readSessionEntries(file) {
86
84
  return entries;
87
85
  }
88
86
 
87
+ // Parsed entries + summary + tool/subagent indices keyed by file path; (mtimeMs, size) is
88
+ // the validity tag. Invalidated by chokidar events via invalidateSessionCache().
89
+ const sessionCache = new Map();
90
+
91
+ function buildToolMaps(entries) {
92
+ const toolResultByCallId = new Map();
93
+ const subagentInfoByCallId = new Map();
94
+ for (const e of entries) {
95
+ if (e.type !== 'message') continue;
96
+ const m = e.message;
97
+ if (m && m.role === 'toolResult' && m.toolCallId) {
98
+ toolResultByCallId.set(m.toolCallId, m);
99
+ if (m.toolName === 'subagent' && m.details && Array.isArray(m.details.results) && m.details.results[0]) {
100
+ const r = m.details.results[0];
101
+ subagentInfoByCallId.set(m.toolCallId, {
102
+ runId: m.details.runId || null,
103
+ agent: r.agent || null,
104
+ runIndex: 0,
105
+ outputPath: r.artifactPaths && r.artifactPaths.outputPath ? r.artifactPaths.outputPath : null,
106
+ inputPath: r.artifactPaths && r.artifactPaths.inputPath ? r.artifactPaths.inputPath : null,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ return { toolResultByCallId, subagentInfoByCallId };
112
+ }
113
+
114
+ async function getCachedSession(file, knownStat = null) {
115
+ const mtimeMs = knownStat ? knownStat.mtimeMs : null;
116
+ const size = knownStat ? knownStat.size : null;
117
+ const cached = sessionCache.get(file);
118
+ if (cached && knownStat && cached.mtimeMs === mtimeMs && cached.size === size) return cached;
119
+ const stat = knownStat || await fs.promises.stat(file);
120
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached;
121
+ const entries = await readSessionEntriesUncached(file);
122
+ const summary = summarize(entries);
123
+ const { toolResultByCallId, subagentInfoByCallId } = buildToolMaps(entries);
124
+ const entry = { mtimeMs: stat.mtimeMs, size: stat.size, entries, summary, toolResultByCallId, subagentInfoByCallId };
125
+ sessionCache.set(file, entry);
126
+ return entry;
127
+ }
128
+
129
+ async function readSessionEntries(file) {
130
+ return (await getCachedSession(file)).entries;
131
+ }
132
+
133
+ function invalidateSessionCache(file) {
134
+ if (file) sessionCache.delete(file);
135
+ else sessionCache.clear();
136
+ }
137
+
89
138
  // Extract concrete agent spawns from a `subagent` toolCall arguments.
90
139
  // Returns [] for management calls (action:"list" etc) so they don't pollute the agent list.
91
140
  // Single spawn: {agent, task} -> [{agent, task}]
@@ -120,6 +169,12 @@ function expandSubagentSpawns(args) {
120
169
  return [];
121
170
  }
122
171
 
172
+ function lastMapValue(map) {
173
+ let v;
174
+ for (v of map.values());
175
+ return v;
176
+ }
177
+
123
178
  function summarize(entries) {
124
179
  let sessionEntry = null;
125
180
  let firstModel = null;
@@ -144,6 +199,8 @@ function summarize(entries) {
144
199
  const subagentByToolCallId = new Map();
145
200
  // `${runId}_${index}` -> rec, for /run slash-invoked subagents (custom_message)
146
201
  const subagentByRunKey = new Map();
202
+ // toolCallId -> { timestamp } for unanswered ask_user_question calls
203
+ const pendingAskUsers = new Map();
147
204
 
148
205
  for (const e of entries) {
149
206
  if (e.type === 'session') sessionEntry = e;
@@ -178,7 +235,9 @@ function summarize(entries) {
178
235
  for (const c of m.content) {
179
236
  if (c && c.type === 'toolCall') {
180
237
  toolCalls++;
181
- if (c.name === 'subagent') {
238
+ if (c.name === 'ask_user_question' && c.id) {
239
+ pendingAskUsers.set(c.id, { timestamp: e.timestamp || null });
240
+ } else if (c.name === 'subagent') {
182
241
  const items = expandSubagentSpawns(c.arguments);
183
242
  if (items.length) {
184
243
  const recs = items.map((it, i) => ({
@@ -205,11 +264,25 @@ function summarize(entries) {
205
264
  }
206
265
  } else if (m.role === 'toolResult') {
207
266
  toolResults++;
208
- if (m.toolName === 'todo' && m.details && Array.isArray(m.details.tasks)) {
267
+ if (m.toolName === 'ask_user_question' && m.toolCallId) {
268
+ pendingAskUsers.delete(m.toolCallId);
269
+ } else if (m.toolName === 'todo' && m.details && Array.isArray(m.details.tasks)) {
209
270
  lastTodoTasks = m.details.tasks;
210
- } else if (m.toolName === 'subagent' && m.details && Array.isArray(m.details.results)) {
271
+ } else if (m.toolName === 'subagent') {
211
272
  const pendingArr = m.toolCallId ? subagentByToolCallId.get(m.toolCallId) : null;
212
- m.details.results.forEach((r, idx) => {
273
+ const results = m.details && Array.isArray(m.details.results) ? m.details.results : null;
274
+ if (!results && pendingArr && pendingArr.length) {
275
+ // Validation/spawn-time failure: pi wrote an error toolResult with no results.
276
+ const errText = Array.isArray(m.content)
277
+ ? (m.content.find((c) => c && c.type === 'text')?.text || '')
278
+ : '';
279
+ for (const rec of pendingArr) {
280
+ rec.stoppedAt = e.timestamp || null;
281
+ rec._error = rec._error || (errText ? errText.split('\n')[0].slice(0, 200) : 'Spawn failed');
282
+ if (rec.exitCode == null) rec.exitCode = -1;
283
+ }
284
+ }
285
+ (results || []).forEach((r, idx) => {
213
286
  let target = pendingArr && pendingArr[idx];
214
287
  if (!target) {
215
288
  const base = pendingArr && pendingArr[0];
@@ -222,7 +295,7 @@ function summarize(entries) {
222
295
  };
223
296
  subagents.push(target);
224
297
  }
225
- target.runId = m.details.runId || target.runId;
298
+ target.runId = (m.details && m.details.runId) || target.runId;
226
299
  target.runIndex = idx;
227
300
  target.agent = r.agent || target.agent;
228
301
  target.prompt = target.prompt || r.task || null;
@@ -233,8 +306,11 @@ function summarize(entries) {
233
306
  target.sessionFile = r.sessionFile || null;
234
307
  target.artifactPaths = r.artifactPaths || null;
235
308
  target.stoppedAt = e.timestamp || null;
309
+ if (r.exitCode != null && r.exitCode !== 0 && !r.error && (r.finalOutput == null || r.finalOutput === '')) {
310
+ target._cancelled = true;
311
+ }
236
312
  });
237
- if (m.details.results.length === 0 && pendingArr && pendingArr.length) {
313
+ if (results && results.length === 0 && pendingArr && pendingArr.length) {
238
314
  const text = Array.isArray(m.content) ? (m.content.find((c) => c && c.type === 'text')?.text || '') : '';
239
315
  const cancelled = /cancel/i.test(text);
240
316
  for (const rec of pendingArr) {
@@ -290,6 +366,9 @@ function summarize(entries) {
290
366
  // pi emits progress events without these fields; their presence marks completion.
291
367
  const isFinal = !!(r.model || r.artifactPaths || r.sessionFile || r.error);
292
368
  if (isFinal) target.stoppedAt = e.timestamp || target.stoppedAt;
369
+ if (isFinal && r.exitCode != null && r.exitCode !== 0 && !r.error && (r.finalOutput == null || r.finalOutput === '')) {
370
+ target._cancelled = true;
371
+ }
293
372
  });
294
373
  }
295
374
  }
@@ -316,6 +395,7 @@ function summarize(entries) {
316
395
  lastAssistantUsage,
317
396
  lastTodoTasks,
318
397
  subagents,
398
+ pendingAskUser: pendingAskUsers.size > 0 ? lastMapValue(pendingAskUsers) : null,
319
399
  };
320
400
  }
321
401
 
@@ -328,15 +408,18 @@ function agentIdFor(rec) {
328
408
 
329
409
  function subagentToApi(rec, lastMessage) {
330
410
  const stopped = !!rec.stoppedAt;
411
+ const cancelled = !!rec._cancelled;
331
412
  let desc = rec.progressSummary
332
413
  ? `${rec.progressSummary.toolCount ?? 0} tools · ${rec.progressSummary.tokens ?? 0} tokens`
333
414
  : null;
334
415
  if (rec._error) desc = `error · ${String(rec._error).split('\n')[0].slice(0, 80)}`;
416
+ else if (cancelled) desc = desc ? `cancelled · ${desc}` : 'cancelled';
335
417
  return {
336
418
  agentId: agentIdFor(rec),
337
419
  type: rec.agent || null,
338
420
  agentName: rec.agent || null,
339
- status: stopped ? 'stopped' : 'active',
421
+ status: stopped ? (cancelled ? 'cancelled' : 'stopped') : 'active',
422
+ cancelled,
340
423
  startedAt: rec.startedAt || null,
341
424
  stoppedAt: rec.stoppedAt || null,
342
425
  updatedAt: rec.stoppedAt || rec.startedAt || null,
@@ -358,7 +441,7 @@ async function readSubagentOutput(rec) {
358
441
  }
359
442
 
360
443
  async function enrichPendingSubagents(meta, subagents) {
361
- let pending = subagents.filter((r) => !r.runId);
444
+ let pending = subagents.filter((r) => !r.runId && !r.stoppedAt);
362
445
  if (pending.length === 0) return;
363
446
  const artifactsDir = path.join(path.dirname(meta.file), 'subagent-artifacts');
364
447
  let files = null;
@@ -403,10 +486,11 @@ async function enrichPendingSubagents(meta, subagents) {
403
486
  };
404
487
  rec.sessionFile = rec.artifactPaths.jsonlPath;
405
488
  rec._error = m.error || null;
489
+ if (m.exitCode != null && m.exitCode !== 0 && !m.error) rec._cancelled = true;
406
490
  if (m.toolCount != null) rec.progressSummary = { toolCount: m.toolCount, tokens: (m.usage?.input || 0) + (m.usage?.output || 0) };
407
491
  }
408
492
 
409
- pending = subagents.filter((r) => !r.runId);
493
+ pending = subagents.filter((r) => !r.runId && !r.stoppedAt);
410
494
  if (pending.length === 0) return;
411
495
  await matchLiveSubagentRuns(meta, pending, subagents);
412
496
  }
@@ -470,28 +554,35 @@ async function matchLiveSubagentRuns(meta, pending, allSubagents) {
470
554
  rec.sessionFile = lr.jsonl;
471
555
  }
472
556
 
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);
557
+ // Structural staleness: while a subagent is running, the parent assistant is blocked
558
+ // waiting on its toolResult, so the parent jsonl can't have any later message other
559
+ // than that toolResult. If we see a newer message that isn't this subagent's own
560
+ // toolResult, pi has moved on and the spawn is orphaned. Tradeoff: a real pi crash
561
+ // with no further parent activity stays as "active" until the user restarts pi.
562
+ const parentEntries = (await getCachedSession(meta.file)).entries;
478
563
  for (const rec of pending) {
479
564
  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();
565
+ if (!rec.startedAt) continue;
566
+ const startMs = new Date(rec.startedAt).getTime();
567
+ if (!startMs) continue;
568
+ const movedOn = parentEntries.some((e) => {
569
+ if (e.type !== 'message') return false;
570
+ const t = e.timestamp ? new Date(e.timestamp).getTime() : 0;
571
+ if (t <= startMs) return false;
572
+ const m = e.message;
573
+ if (m && m.role === 'toolResult' && m.toolName === 'subagent'
574
+ && rec.toolCallId && m.toolCallId === rec.toolCallId) return false;
575
+ return true;
576
+ });
577
+ if (movedOn) {
578
+ rec.stoppedAt = new Date().toISOString();
487
579
  rec._error = rec._error || 'Interrupted (no result recorded)';
488
580
  }
489
581
  }
490
582
  }
491
583
 
492
- async function listAgentsForSession(meta) {
493
- const entries = await readSessionEntries(meta.file);
494
- const s = summarize(entries);
584
+ async function listAgentsForSession(meta, summary = null) {
585
+ const s = summary || (await getCachedSession(meta.file)).summary;
495
586
  const recs = s.subagents || [];
496
587
  await enrichPendingSubagents(meta, recs);
497
588
  const out = [];
@@ -509,13 +600,15 @@ async function findAgentRecord(meta, agentId) {
509
600
  }
510
601
 
511
602
  function tallyTasks(tasks) {
512
- let pending = 0, inProgress = 0, completed = 0;
603
+ let pending = 0, inProgress = 0, completed = 0, total = 0;
513
604
  for (const t of tasks) {
605
+ if (t.status === 'deleted') continue;
606
+ total++;
514
607
  if (t.status === 'completed') completed++;
515
608
  else if (t.status === 'in_progress') inProgress++;
516
609
  else pending++;
517
610
  }
518
- return { taskCount: tasks.length, pending, inProgress, completed };
611
+ return { taskCount: total, pending, inProgress, completed };
519
612
  }
520
613
 
521
614
  const MODEL_CONTEXT_WINDOWS = [
@@ -555,8 +648,8 @@ function slugFromFile(file) {
555
648
  const RECENT_MS = 5 * 60 * 1000;
556
649
 
557
650
  async function buildSessionSummary(meta) {
558
- const entries = await readSessionEntries(meta.file);
559
- const s = summarize(entries);
651
+ const cached = await getCachedSession(meta.file, meta.mtimeMs != null ? { mtimeMs: meta.mtimeMs, size: meta.size } : null);
652
+ const s = cached.summary;
560
653
  const id = (s.sessionEntry && s.sessionEntry.id) || slugFromFile(meta.file);
561
654
  const cwd = (s.sessionEntry && s.sessionEntry.cwd) || meta.cwd;
562
655
  const projectName = path.basename(cwd) || cwd;
@@ -574,13 +667,13 @@ async function buildSessionSummary(meta) {
574
667
  }),
575
668
  customTitle: s.customTitle || null,
576
669
  parentSession: (s.sessionEntry && s.sessionEntry.parentSession) || null,
577
- ...tallyTasks(taskStore.listTasks(id)),
670
+ ...tallyTasks(await taskStore.listTasksAsync(id)),
578
671
  createdAt: s.sessionEntry && s.sessionEntry.timestamp,
579
672
  modifiedAt: meta.mtime.toISOString(),
580
673
  hasMessages: s.messageCount > 0,
581
674
  hasActiveAgents: false,
582
675
  hasRunningAgents: false,
583
- hasWaitingForUser: false,
676
+ hasWaitingForUser: !!s.pendingAskUser,
584
677
  hasRecentLog: isRecent,
585
678
  jsonlPath: meta.file,
586
679
  tasksDir: null,
@@ -637,26 +730,26 @@ async function buildSessionSummary(meta) {
637
730
  }
638
731
 
639
732
  async function listSessions() {
640
- const files = listSessionFiles();
733
+ const files = await listSessionFiles();
641
734
  files.sort((a, b) => b.mtime - a.mtime);
735
+ const summaries = await Promise.all(files.map(async (f) => {
736
+ try { return await buildSessionSummary(f); }
737
+ catch { return null; }
738
+ }));
642
739
  const out = [];
643
740
  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
- }
741
+ for (const summary of summaries) {
742
+ if (!summary) continue;
743
+ if (summary.parentSession) continue;
744
+ if (seen.has(summary.id)) continue;
745
+ seen.add(summary.id);
746
+ out.push(summary);
654
747
  }
655
748
  return out;
656
749
  }
657
750
 
658
751
  async function findSessionFileById(id) {
659
- const files = listSessionFiles();
752
+ const files = await listSessionFiles();
660
753
  files.sort((a, b) => b.mtime - a.mtime);
661
754
  // First try filename match (faster)
662
755
  for (const f of files) {
@@ -714,22 +807,36 @@ function computeToolDetail(name, params) {
714
807
  }
715
808
  let detail = null;
716
809
  let full = null;
810
+ const trunc = (s, n = 80) => (s.length > n ? s.slice(0, n) + '...' : s);
717
811
  if (params.file_path) { detail = path.basename(params.file_path); full = params.file_path; }
718
812
  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; }
813
+ else if (params.command) { full = params.command; detail = trunc(full); }
720
814
  else if (params.pattern) { full = params.pattern; detail = full; }
721
815
  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; }
816
+ else if (params.queries) { full = Array.isArray(params.queries) ? params.queries.join(' | ') : String(params.queries); detail = trunc(full); }
817
+ else if (params.url) { full = params.url; detail = trunc(full); }
818
+ else if (params.task) { full = params.task; detail = trunc(full); }
725
819
  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; }
820
+ else if (params.description) { full = params.description; detail = trunc(full); }
821
+ else if (params.tasks && Array.isArray(params.tasks)) {
822
+ const agents = [...new Set(params.tasks.map((t) => t.agent).filter(Boolean))];
823
+ const n = params.tasks.length;
824
+ const agentStr = agents.length ? ` · ${agents.join(', ')}` : '';
825
+ detail = `${n} task${n > 1 ? 's' : ''}${agentStr}`;
826
+ full = detail;
827
+ }
828
+ else if (params.question) { full = params.question; detail = trunc(full); }
829
+ else if (params.questions && Array.isArray(params.questions)) {
830
+ const first = params.questions[0]?.question || '';
831
+ const extra = params.questions.length > 1 ? ` (+${params.questions.length - 1} more)` : '';
832
+ full = params.questions.map((q) => q.question).join(' | ');
833
+ detail = trunc(first, 60) + extra;
834
+ }
728
835
  else if (params.action) { detail = params.action; full = detail; }
729
836
  else if (params.operation) { detail = params.operation; full = detail; }
730
837
  else {
731
838
  try { full = JSON.stringify(params); } catch { full = ''; }
732
- detail = full.length > 80 ? full.slice(0, 80) + '...' : full;
839
+ detail = trunc(full);
733
840
  }
734
841
  return { detail: detail || '', fullDetail: full && full !== detail ? full : null };
735
842
  }
@@ -767,26 +874,18 @@ async function readFileSafe(p) {
767
874
  }
768
875
 
769
876
  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
- }
877
+ const cached = await getCachedSession(file);
878
+ if (cached.allMessages) {
879
+ const filtered = before
880
+ ? cached.allMessages.filter((m) => m.timestamp && m.timestamp < before)
881
+ : cached.allMessages;
882
+ const total = filtered.length;
883
+ const sliced = limit && limit < total ? filtered.slice(-limit) : filtered;
884
+ return { messages: sliced, hasMore: sliced.length < total, sessionId: null };
789
885
  }
886
+ const entries = cached.entries;
887
+ const toolResultByCallId = cached.toolResultByCallId;
888
+ const subagentInfoByCallId = cached.subagentInfoByCallId;
790
889
 
791
890
  const messages = [];
792
891
  for (const e of entries) {
@@ -840,11 +939,15 @@ async function buildMessages(file, limit = 50, before = null) {
840
939
  toolResult: trText ? trText.slice(0, 1500) : null,
841
940
  toolResultTruncated: trText ? trText.length > 1500 : false,
842
941
  };
942
+ if (c.name === 'ask_user_question' && tr && tr.details && !tr.isError) {
943
+ msg.toolResultDetails = tr.details;
944
+ }
843
945
  if (c.name === 'subagent') {
844
946
  const info = subagentInfoByCallId.get(c.id);
845
947
  const agent = (c.arguments && c.arguments.agent) || (info && info.agent) || null;
846
948
  const runId = info && info.runId ? info.runId : null;
847
949
  const isSpawn = !!(c.arguments && c.arguments.task);
950
+ const isParallel = !!(c.arguments && Array.isArray(c.arguments.tasks));
848
951
  if (isSpawn) {
849
952
  msg.tool = 'Agent';
850
953
  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)}`);
@@ -853,6 +956,11 @@ async function buildMessages(file, limit = 50, before = null) {
853
956
  msg.agentPrompt = c.arguments.task || null;
854
957
  msg.agentLastMessage = info && info.outputPath ? await readFileSafe(info.outputPath) : null;
855
958
  if (!msg.agentLastMessage && trText) msg.agentLastMessage = trText;
959
+ } else if (isParallel) {
960
+ msg.agentTasks = c.arguments.tasks;
961
+ if (tr && tr.details && Array.isArray(tr.details.results)) {
962
+ msg.parallelResults = tr.details.results;
963
+ }
856
964
  }
857
965
  }
858
966
  messages.push(msg);
@@ -863,6 +971,7 @@ async function buildMessages(file, limit = 50, before = null) {
863
971
  // toolResult handled via map above.
864
972
  }
865
973
 
974
+ cached.allMessages = messages;
866
975
  const filtered = before
867
976
  ? messages.filter((m) => m.timestamp && m.timestamp < before)
868
977
  : messages;
@@ -886,4 +995,6 @@ module.exports = {
886
995
  findAgentRecord,
887
996
  slugFromFile,
888
997
  setOnBranchResolved,
998
+ flattenContentToText,
999
+ invalidateSessionCache,
889
1000
  };