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 +9 -4
- package/extensions/kanban.ts +84 -1
- package/lib/pi-parsers.js +203 -92
- package/lib/task-store.js +22 -4
- package/package.json +11 -3
- package/public/app.js +5854 -5690
- package/public/style.css +153 -1
- package/server.js +169 -47
package/README.md
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](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
|

|
|
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
|
-
-
|
|
46
|
-
- [Theming](https://github.com/NikiforovAll/pi-kanban/blob/main/docs/theming.md) — built-in themes, configuration, custom theme authoring.
|
|
51
|
+

|
package/extensions/kanban.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
44
|
-
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const full = path.join(projPath, f);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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 === '
|
|
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 === '
|
|
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'
|
|
271
|
+
} else if (m.toolName === 'subagent') {
|
|
211
272
|
const pendingArr = m.toolCallId ? subagentByToolCallId.get(m.toolCallId) : null;
|
|
212
|
-
m.details.results.
|
|
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 (
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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:
|
|
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
|
|
559
|
-
const s =
|
|
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.
|
|
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:
|
|
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
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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
|
|
723
|
-
else if (params.url) { full = params.url; detail = full
|
|
724
|
-
else if (params.task) { full = params.task; detail = 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
|
|
727
|
-
else if (params.
|
|
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
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
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
|
};
|