pi-kanban 0.3.1 → 1.0.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 +1 -1
- package/extensions/kanban.ts +130 -76
- package/extensions/package.json +1 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/pi-parsers.js +176 -21
- package/package.json +8 -4
- package/public/app.js +60 -47
- package/public/index.html +12 -5
- package/public/style.css +89 -1
- package/server.js +43 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ When you're ready to run it on your own pi sessions, install below.
|
|
|
24
24
|
pi install npm:pi-kanban
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Then use `/kanban start | stop | restart | status | open |
|
|
27
|
+
Then use `/kanban start | stop | restart | status | open web|app` for global controls, and `/kanban session open | pin | sticky-pin | unpin | view-doc | link-doc` for session-scoped actions.
|
|
28
28
|
|
|
29
29
|
Or run standalone:
|
|
30
30
|
|
package/extensions/kanban.ts
CHANGED
|
@@ -6,9 +6,10 @@ 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
8
|
import { Type } from "typebox";
|
|
9
|
-
import type { ExtensionAPI } from "@
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const extDir = fileURLToPath(new URL('.', import.meta.url));
|
|
10
12
|
|
|
11
|
-
const extDir = fileURLToPath(new URL(".", import.meta.url));
|
|
12
13
|
const taskStore = createRequire(import.meta.url)(
|
|
13
14
|
resolvePath(extDir, "..", "lib", "task-store.js"),
|
|
14
15
|
) as {
|
|
@@ -61,15 +62,19 @@ const SUBCOMMANDS = [
|
|
|
61
62
|
"restart",
|
|
62
63
|
"status",
|
|
63
64
|
"open",
|
|
64
|
-
"
|
|
65
|
-
|
|
65
|
+
"session",
|
|
66
|
+
] as const;
|
|
67
|
+
type Sub = (typeof SUBCOMMANDS)[number];
|
|
68
|
+
|
|
69
|
+
const SESSION_SUBCOMMANDS = [
|
|
70
|
+
"open",
|
|
66
71
|
"pin",
|
|
67
72
|
"sticky-pin",
|
|
68
73
|
"unpin",
|
|
69
|
-
"
|
|
70
|
-
"link",
|
|
74
|
+
"view-doc",
|
|
75
|
+
"link-doc",
|
|
71
76
|
] as const;
|
|
72
|
-
type
|
|
77
|
+
type SessionSub = (typeof SESSION_SUBCOMMANDS)[number];
|
|
73
78
|
|
|
74
79
|
function probePort(p: number, timeoutMs = 250): Promise<boolean> {
|
|
75
80
|
return new Promise((resolve) => {
|
|
@@ -231,7 +236,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
231
236
|
const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
|
|
232
237
|
const inputPath = String(params.path ?? "").trim();
|
|
233
238
|
if (!inputPath) {
|
|
234
|
-
return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
|
|
239
|
+
return { content: [{ type: "text", text: "Error: path is required." }], isError: true, details: { path: inputPath, title: null, sessionId } };
|
|
235
240
|
}
|
|
236
241
|
const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
|
|
237
242
|
|
|
@@ -239,7 +244,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
239
244
|
try {
|
|
240
245
|
const stat = statSync(absPath);
|
|
241
246
|
if (!stat.isFile()) {
|
|
242
|
-
return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
|
|
247
|
+
return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true, details: { path: absPath, title: null, sessionId } };
|
|
243
248
|
}
|
|
244
249
|
const buf = readFileSync(absPath, { encoding: "utf8" });
|
|
245
250
|
const head = buf.slice(0, PLAN_MAX_BYTES);
|
|
@@ -248,6 +253,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
248
253
|
return {
|
|
249
254
|
content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
|
|
250
255
|
isError: true,
|
|
256
|
+
details: { path: absPath, title: null, sessionId },
|
|
251
257
|
};
|
|
252
258
|
}
|
|
253
259
|
|
|
@@ -255,6 +261,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
255
261
|
return {
|
|
256
262
|
content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
|
|
257
263
|
isError: true,
|
|
264
|
+
details: { path: absPath, title: title ?? null, sessionId },
|
|
258
265
|
};
|
|
259
266
|
}
|
|
260
267
|
try {
|
|
@@ -262,12 +269,12 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
262
269
|
if (!res.ok) {
|
|
263
270
|
const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
|
|
264
271
|
console.warn(msg);
|
|
265
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
272
|
+
return { content: [{ type: "text", text: msg }], isError: true, details: { path: absPath, title, sessionId } };
|
|
266
273
|
}
|
|
267
274
|
} catch (e: any) {
|
|
268
275
|
const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
|
|
269
276
|
console.warn(msg);
|
|
270
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
277
|
+
return { content: [{ type: "text", text: msg }], isError: true, details: { path: absPath, title: title ?? null, sessionId } };
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
|
|
@@ -282,7 +289,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
282
289
|
|
|
283
290
|
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
|
284
291
|
if (await probePort(port)) {
|
|
285
|
-
notify(`pi-kanban already listening on ${url} — run /kanban open
|
|
292
|
+
notify(`pi-kanban already listening on ${url} — run /kanban open web to launch it`);
|
|
286
293
|
return true;
|
|
287
294
|
}
|
|
288
295
|
lastStderr = "";
|
|
@@ -312,7 +319,7 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
312
319
|
child.stderr?.resume();
|
|
313
320
|
child.unref();
|
|
314
321
|
if (!opts.silentSuccess) {
|
|
315
|
-
notify(`pi-kanban started → ${url} — run /kanban open
|
|
322
|
+
notify(`pi-kanban started → ${url} — run /kanban open web to launch it`);
|
|
316
323
|
}
|
|
317
324
|
return true;
|
|
318
325
|
}
|
|
@@ -344,15 +351,46 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
344
351
|
|
|
345
352
|
pi.registerCommand("kanban", {
|
|
346
353
|
description:
|
|
347
|
-
"pi-kanban dashboard: start | stop | restart | status | open
|
|
348
|
-
getArgumentCompletions: (prefix) =>
|
|
349
|
-
|
|
354
|
+
"pi-kanban dashboard: start | stop | restart | status | open web|app | session <verb>",
|
|
355
|
+
getArgumentCompletions: (prefix) => {
|
|
356
|
+
const tokens = splitArgs(prefix);
|
|
357
|
+
if (tokens.length >= 1 && tokens[0] === "session") {
|
|
358
|
+
const verbPrefix = tokens[1] ?? "";
|
|
359
|
+
return SESSION_SUBCOMMANDS
|
|
360
|
+
.filter((s) => s.startsWith(verbPrefix))
|
|
361
|
+
.map((s) => ({ value: `session ${s}`, label: s }));
|
|
362
|
+
}
|
|
363
|
+
return SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
364
|
+
},
|
|
350
365
|
handler: async (args, ctx) => {
|
|
351
366
|
const tokens = splitArgs(args);
|
|
352
367
|
const sub = (tokens[0] || "start") as Sub;
|
|
353
368
|
const rest = tokens.slice(1);
|
|
354
369
|
const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
|
|
355
370
|
|
|
371
|
+
function showHelp() {
|
|
372
|
+
const lines = [
|
|
373
|
+
"Usage: /kanban <command>",
|
|
374
|
+
"",
|
|
375
|
+
"Global commands:",
|
|
376
|
+
" start Start the dashboard server",
|
|
377
|
+
" stop Stop the dashboard server",
|
|
378
|
+
" restart Restart the dashboard server",
|
|
379
|
+
" status Show server status",
|
|
380
|
+
" open web Open dashboard in browser",
|
|
381
|
+
" open app Open dashboard as PWA",
|
|
382
|
+
"",
|
|
383
|
+
"Session commands (default to current session):",
|
|
384
|
+
" session open [<id>]",
|
|
385
|
+
" session pin [<id>]",
|
|
386
|
+
" session sticky-pin [<id>]",
|
|
387
|
+
" session unpin [<id>]",
|
|
388
|
+
" session view-doc <file> [<id>]",
|
|
389
|
+
" session link-doc <file> [<id>]",
|
|
390
|
+
];
|
|
391
|
+
notify(lines.join("\n"));
|
|
392
|
+
}
|
|
393
|
+
|
|
356
394
|
if (sub === "start") {
|
|
357
395
|
await startServer(notify);
|
|
358
396
|
return;
|
|
@@ -378,88 +416,104 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
378
416
|
}
|
|
379
417
|
|
|
380
418
|
if (sub === "open") {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
419
|
+
const verb = rest[0];
|
|
420
|
+
if (verb === "web") {
|
|
421
|
+
const { default: open } = await import("open");
|
|
422
|
+
await open(url);
|
|
385
423
|
return;
|
|
386
424
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
425
|
+
if (verb === "app") {
|
|
426
|
+
const { default: open, apps } = await import("open");
|
|
427
|
+
for (const name of [apps.chrome, apps.edge, apps.browser]) {
|
|
428
|
+
try {
|
|
429
|
+
await open(url, { app: { name, arguments: [`--app=${url}`] } });
|
|
430
|
+
return;
|
|
431
|
+
} catch {}
|
|
432
|
+
}
|
|
433
|
+
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
390
434
|
return;
|
|
391
435
|
}
|
|
392
|
-
|
|
393
|
-
notify(`opened: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
436
|
+
notify(`Usage: /kanban open web|app${verb ? ` (unknown: ${verb})` : ""}`, "error");
|
|
394
437
|
return;
|
|
395
438
|
}
|
|
396
439
|
|
|
397
|
-
if (sub === "
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (sub === "app") {
|
|
404
|
-
const { default: open, apps } = await import("open");
|
|
405
|
-
for (const name of [apps.chrome, apps.edge, apps.browser]) {
|
|
406
|
-
try {
|
|
407
|
-
await open(url, { app: { name, arguments: [`--app=${url}`] } });
|
|
408
|
-
return;
|
|
409
|
-
} catch {}
|
|
410
|
-
}
|
|
411
|
-
notify("Could not find Chrome/Edge for PWA window mode", "error");
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
440
|
+
if (sub === "session") {
|
|
441
|
+
const verb = rest[0] as SessionSub | undefined;
|
|
442
|
+
const verbRest = rest.slice(1);
|
|
414
443
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
418
|
-
if (!id) {
|
|
419
|
-
notify(`Usage: /kanban ${sub} <session-id> (no current session to default to)`, "error");
|
|
444
|
+
if (!verb) {
|
|
445
|
+
showHelp();
|
|
420
446
|
return;
|
|
421
447
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!res.ok) {
|
|
426
|
-
notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
|
|
448
|
+
|
|
449
|
+
if (!(SESSION_SUBCOMMANDS as readonly string[]).includes(verb)) {
|
|
450
|
+
notify(`Unknown session verb: ${verb}. Try: ${SESSION_SUBCOMMANDS.join(", ")}`, "error");
|
|
427
451
|
return;
|
|
428
452
|
}
|
|
429
|
-
const usedCurrent = !rest[0];
|
|
430
|
-
const verb = sub === "unpin" ? "unpinned" : state;
|
|
431
|
-
notify(`${verb}: ${id}${usedCurrent ? " (current)" : ""}`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
453
|
|
|
435
|
-
if (sub === "preview" || sub === "link") {
|
|
436
454
|
if (!(await ensureRunning(notify))) return;
|
|
437
|
-
|
|
438
|
-
if (
|
|
439
|
-
|
|
455
|
+
|
|
456
|
+
if (verb === "open") {
|
|
457
|
+
const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
458
|
+
if (!id) {
|
|
459
|
+
notify("Usage: /kanban session open <session-id> (no current session to default to)", "error");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const res = await postSessionOpen(id);
|
|
463
|
+
if (!res.ok) {
|
|
464
|
+
notify(`open failed (${res.status}): ${await res.text()}`, "error");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
notify(`opened: ${id}${!verbRest[0] ? " (current)" : ""}`);
|
|
440
468
|
return;
|
|
441
469
|
}
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
|
|
470
|
+
|
|
471
|
+
if (verb === "pin" || verb === "sticky-pin" || verb === "unpin") {
|
|
472
|
+
const id = verbRest[0] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
473
|
+
if (!id) {
|
|
474
|
+
notify(`Usage: /kanban session ${verb} <session-id> (no current session to default to)`, "error");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
|
|
478
|
+
const state = PIN_STATE[verb];
|
|
479
|
+
const res = await postPin(id, state);
|
|
480
|
+
if (!res.ok) {
|
|
481
|
+
notify(`${verb} failed (${res.status}): ${await res.text()}`, "error");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const pinVerb = verb === "unpin" ? "unpinned" : state;
|
|
485
|
+
notify(`${pinVerb}: ${id}${!verbRest[0] ? " (current)" : ""}`);
|
|
445
486
|
return;
|
|
446
487
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
488
|
+
|
|
489
|
+
if (verb === "view-doc" || verb === "link-doc") {
|
|
490
|
+
const file = verbRest[0];
|
|
491
|
+
if (!file) {
|
|
492
|
+
notify(`Usage: /kanban session ${verb} <file.md> [session-id]`, "error");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const sessionId = verbRest[1] ?? ctx.sessionManager.getSessionId() ?? null;
|
|
496
|
+
if (verb === "link-doc" && !sessionId) {
|
|
497
|
+
notify("Usage: /kanban session link-doc <file.md> <session-id> (no current session to default to)", "error");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const expanded =
|
|
501
|
+
file === "~" || file.startsWith("~/") || file.startsWith("~\\")
|
|
502
|
+
? joinPath(homedir(), file.slice(1))
|
|
503
|
+
: file;
|
|
504
|
+
const abs = isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
|
|
505
|
+
const res = await postPreview(abs, sessionId, verb === "link-doc");
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
notify(`${verb} failed (${res.status}): ${await res.text()}`, "error");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const usedCurrent = !verbRest[1] && sessionId;
|
|
511
|
+
notify(`${verb}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
|
|
455
512
|
return;
|
|
456
513
|
}
|
|
457
|
-
const usedCurrent = !rest[1] && sessionId;
|
|
458
|
-
notify(`${sub}: ${abs}${sessionId ? ` → session ${sessionId}${usedCurrent ? " (current)" : ""}` : ""}`);
|
|
459
|
-
return;
|
|
460
514
|
}
|
|
461
515
|
|
|
462
|
-
|
|
516
|
+
showHelp();
|
|
463
517
|
},
|
|
464
518
|
});
|
|
465
519
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"lib": ["es2024"],
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["./**/*.ts"]
|
|
16
|
+
}
|
package/lib/pi-parsers.js
CHANGED
|
@@ -9,6 +9,13 @@ const taskStore = require('./task-store');
|
|
|
9
9
|
let onBranchResolved = null;
|
|
10
10
|
function setOnBranchResolved(fn) { onBranchResolved = typeof fn === 'function' ? fn : null; }
|
|
11
11
|
|
|
12
|
+
// Detect local absolute image paths in user message text — Windows and Unix.
|
|
13
|
+
// Lookbehinds reject URL contexts: `(?<![a-zA-Z])` blocks scheme suffix (`s` in `https:`);
|
|
14
|
+
// `(?!\/)` blocks `://`; `(?<![:\w\\/])` blocks `/path` after letter/colon/slash.
|
|
15
|
+
const LOCAL_IMAGE_PATH_RE = /((?<![a-zA-Z])[A-Za-z]:[\\/](?!\/)[^\s"'<>]+?\.(png|jpe?g|gif|webp|bmp)|(?<![:\w\\/])\/[^\s"'<>]+?\.(png|jpe?g|gif|webp|bmp))/i;
|
|
16
|
+
const MEDIA_TYPES_BY_EXT = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp' };
|
|
17
|
+
function guessMediaTypeFromPath(p) { return MEDIA_TYPES_BY_EXT[path.extname(p).toLowerCase()] || 'application/octet-stream'; }
|
|
18
|
+
|
|
12
19
|
const PI_DIR = process.env.PI_DIR || path.join(os.homedir(), '.pi');
|
|
13
20
|
const SESSIONS_DIR = path.join(PI_DIR, 'agent', 'sessions');
|
|
14
21
|
|
|
@@ -74,6 +81,40 @@ function parseLine(line) {
|
|
|
74
81
|
try { return JSON.parse(line); } catch { return null; }
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
// Stream the first `maxLines` lines and extract session-header fields used by
|
|
85
|
+
// subagent matching. Avoids reading whole .jsonl files when only the header is needed.
|
|
86
|
+
async function readSessionHeader(file, maxLines = 10) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const out = { firstTs: null, modelId: null, firstUserText: null, parentSession: null };
|
|
89
|
+
let count = 0;
|
|
90
|
+
let done = false;
|
|
91
|
+
const stream = fs.createReadStream(file, { encoding: 'utf8' });
|
|
92
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
93
|
+
const finish = () => { if (done) return; done = true; rl.close(); stream.destroy(); resolve(out); };
|
|
94
|
+
rl.on('line', (line) => {
|
|
95
|
+
if (done) return;
|
|
96
|
+
if (line.trim()) {
|
|
97
|
+
const ev = parseLine(line);
|
|
98
|
+
if (ev) {
|
|
99
|
+
if (ev.type === 'session') {
|
|
100
|
+
if (!out.firstTs) out.firstTs = ev.timestamp || null;
|
|
101
|
+
if (ev.parentSession) out.parentSession = ev.parentSession;
|
|
102
|
+
}
|
|
103
|
+
if (!out.modelId && ev.type === 'model_change') out.modelId = ev.modelId || null;
|
|
104
|
+
if (!out.firstUserText && ev.type === 'message' && ev.message?.role === 'user') {
|
|
105
|
+
const c = ev.message.content;
|
|
106
|
+
const txt = Array.isArray(c) ? c.find((x) => x && x.type === 'text')?.text : null;
|
|
107
|
+
if (txt) out.firstUserText = txt;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (++count >= maxLines) finish();
|
|
112
|
+
});
|
|
113
|
+
rl.on('close', finish);
|
|
114
|
+
rl.on('error', finish);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
77
118
|
async function readSessionEntriesUncached(file) {
|
|
78
119
|
const lines = await readJsonlLines(file);
|
|
79
120
|
const entries = [];
|
|
@@ -305,17 +346,33 @@ function summarize(entries) {
|
|
|
305
346
|
target.progressSummary = r.progressSummary || null;
|
|
306
347
|
target.sessionFile = r.sessionFile || null;
|
|
307
348
|
target.artifactPaths = r.artifactPaths || null;
|
|
349
|
+
if (r.model) target.model = r.model;
|
|
308
350
|
target.stoppedAt = e.timestamp || null;
|
|
309
351
|
if (r.exitCode != null && r.exitCode !== 0 && !r.error && (r.finalOutput == null || r.finalOutput === '')) {
|
|
310
352
|
target._cancelled = true;
|
|
311
353
|
}
|
|
312
354
|
});
|
|
313
355
|
if (results && results.length === 0 && pendingArr && pendingArr.length) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
rec
|
|
318
|
-
|
|
356
|
+
if (m.details && m.details.asyncId) {
|
|
357
|
+
// Async launch: empty results are expected — completion arrives via subagent-notify.
|
|
358
|
+
// Mark recs so the orphan check and enrichment handle them correctly.
|
|
359
|
+
for (const rec of pendingArr) {
|
|
360
|
+
rec._async = true;
|
|
361
|
+
rec._asyncDir = m.details.asyncDir || null;
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
// Sync empty results: failure or chain cancellation.
|
|
365
|
+
const text = Array.isArray(m.content) ? (m.content.find((c) => c && c.type === 'text')?.text || '') : '';
|
|
366
|
+
const cancelled = /cancel/i.test(text);
|
|
367
|
+
for (const rec of pendingArr) {
|
|
368
|
+
rec.stoppedAt = e.timestamp || null;
|
|
369
|
+
if (cancelled) rec._error = rec._error || 'Chain cancelled';
|
|
370
|
+
else {
|
|
371
|
+
// Spawn failed (e.g. unknown agent): record error and mark so UI can hide it.
|
|
372
|
+
rec._error = rec._error || (text ? text.split('\n')[0].slice(0, 200) : 'Spawn failed');
|
|
373
|
+
rec._spawnFailed = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
319
376
|
}
|
|
320
377
|
}
|
|
321
378
|
}
|
|
@@ -493,6 +550,63 @@ async function enrichPendingSubagents(meta, subagents) {
|
|
|
493
550
|
pending = subagents.filter((r) => !r.runId && !r.stoppedAt);
|
|
494
551
|
if (pending.length === 0) return;
|
|
495
552
|
await matchLiveSubagentRuns(meta, pending, subagents);
|
|
553
|
+
|
|
554
|
+
// For active subagents still missing model info, scan sibling session files that
|
|
555
|
+
// were spawned from this parent session (identified by parentSession containing the
|
|
556
|
+
// parent's UUID). The child session's model_change event appears early (line ~2) and
|
|
557
|
+
// is available while the subagent is still running.
|
|
558
|
+
await findModelFromSiblingSessions(meta, subagents);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Reads sibling .jsonl files in the same sessions directory, finds ones whose
|
|
562
|
+
// `session` entry's `parentSession` field contains the parent session UUID, then
|
|
563
|
+
// assigns the modelId from their early model_change event to matching active subagents.
|
|
564
|
+
async function findModelFromSiblingSessions(meta, subagents) {
|
|
565
|
+
const needsModel = subagents.filter((r) => !r.model && !r.stoppedAt);
|
|
566
|
+
if (needsModel.length === 0) return;
|
|
567
|
+
|
|
568
|
+
const sessionsDir = path.dirname(meta.file);
|
|
569
|
+
const parentSessionId = slugFromFile(meta.file);
|
|
570
|
+
if (!parentSessionId) return;
|
|
571
|
+
|
|
572
|
+
let files;
|
|
573
|
+
try { files = await fs.promises.readdir(sessionsDir); } catch { return; }
|
|
574
|
+
|
|
575
|
+
const candidates = [];
|
|
576
|
+
for (const f of files) {
|
|
577
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
578
|
+
if (f === path.basename(meta.file)) continue;
|
|
579
|
+
const fpath = path.join(sessionsDir, f);
|
|
580
|
+
try {
|
|
581
|
+
const h = await readSessionHeader(fpath);
|
|
582
|
+
// parentSession is path-separator-agnostic; UUID substring match works on Windows/Linux.
|
|
583
|
+
if (h.parentSession && h.parentSession.includes(parentSessionId)) {
|
|
584
|
+
candidates.push({ fpath, modelId: h.modelId, firstTs: h.firstTs, firstUserText: h.firstUserText });
|
|
585
|
+
}
|
|
586
|
+
} catch {}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (candidates.length === 0) return;
|
|
590
|
+
|
|
591
|
+
const used = new Set();
|
|
592
|
+
for (const rec of needsModel) {
|
|
593
|
+
const startMs = rec.startedAt ? new Date(rec.startedAt).getTime() : 0;
|
|
594
|
+
const idx = candidates.findIndex((c, i) => {
|
|
595
|
+
if (used.has(i)) return false;
|
|
596
|
+
const cMs = c.firstTs ? new Date(c.firstTs).getTime() : 0;
|
|
597
|
+
// Child session must start within a reasonable window of the subagent spawn time
|
|
598
|
+
if (startMs && cMs && (cMs < startMs - 2000 || cMs > startMs + 60000)) return false;
|
|
599
|
+
// For fork-mode subagents the first user message in the child session is the
|
|
600
|
+
// inherited parent prompt, not the task text — so don't reject on mismatch.
|
|
601
|
+
// Timestamp proximity + parentSession UUID match is sufficient.
|
|
602
|
+
return true;
|
|
603
|
+
});
|
|
604
|
+
if (idx < 0) continue;
|
|
605
|
+
used.add(idx);
|
|
606
|
+
const c = candidates[idx];
|
|
607
|
+
if (c.modelId) rec.model = c.modelId;
|
|
608
|
+
if (!rec.sessionFile) rec.sessionFile = c.fpath;
|
|
609
|
+
}
|
|
496
610
|
}
|
|
497
611
|
|
|
498
612
|
async function matchLiveSubagentRuns(meta, pending, allSubagents) {
|
|
@@ -514,21 +628,8 @@ async function matchLiveSubagentRuns(meta, pending, allSubagents) {
|
|
|
514
628
|
const jsonl = path.join(runDirPath, r, 'session.jsonl');
|
|
515
629
|
try {
|
|
516
630
|
const stat = await fs.promises.stat(jsonl);
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
let firstTs = null;
|
|
520
|
-
let firstUserText = null;
|
|
521
|
-
for (const line of lines) {
|
|
522
|
-
try {
|
|
523
|
-
const ev = JSON.parse(line);
|
|
524
|
-
if (ev.type === 'session' && !firstTs) firstTs = ev.timestamp;
|
|
525
|
-
if (!firstUserText && ev.type === 'message' && ev.message && ev.message.role === 'user' && Array.isArray(ev.message.content)) {
|
|
526
|
-
const txt = ev.message.content.find((c) => c && c.type === 'text');
|
|
527
|
-
if (txt && typeof txt.text === 'string') firstUserText = txt.text;
|
|
528
|
-
}
|
|
529
|
-
} catch {}
|
|
530
|
-
}
|
|
531
|
-
liveRuns.push({ runId: d.name, runIndex: parseInt(m[1], 10), jsonl, firstTs, firstUserText, mtime: stat.mtimeMs });
|
|
631
|
+
const h = await readSessionHeader(jsonl);
|
|
632
|
+
liveRuns.push({ runId: d.name, runIndex: parseInt(m[1], 10), jsonl, firstTs: h.firstTs, firstUserText: h.firstUserText, modelId: h.modelId, mtime: stat.mtimeMs });
|
|
532
633
|
} catch {}
|
|
533
634
|
}
|
|
534
635
|
}
|
|
@@ -552,6 +653,17 @@ async function matchLiveSubagentRuns(meta, pending, allSubagents) {
|
|
|
552
653
|
rec.runId = lr.runId;
|
|
553
654
|
rec.runIndex = lr.runIndex;
|
|
554
655
|
rec.sessionFile = lr.jsonl;
|
|
656
|
+
if (lr.modelId && !rec.model) rec.model = lr.modelId;
|
|
657
|
+
// For async subagents, check status.json to detect completion and set stoppedAt.
|
|
658
|
+
if (rec._async && rec._asyncDir && !rec.stoppedAt) {
|
|
659
|
+
try {
|
|
660
|
+
const statusRaw = await fs.promises.readFile(path.join(rec._asyncDir, 'status.json'), 'utf8');
|
|
661
|
+
const statusData = JSON.parse(statusRaw);
|
|
662
|
+
if (statusData.state === 'complete' && statusData.lastActivityAt) {
|
|
663
|
+
rec.stoppedAt = new Date(statusData.lastActivityAt).toISOString();
|
|
664
|
+
}
|
|
665
|
+
} catch {}
|
|
666
|
+
}
|
|
555
667
|
}
|
|
556
668
|
|
|
557
669
|
// Structural staleness: while a subagent is running, the parent assistant is blocked
|
|
@@ -562,6 +674,7 @@ async function matchLiveSubagentRuns(meta, pending, allSubagents) {
|
|
|
562
674
|
const parentEntries = (await getCachedSession(meta.file)).entries;
|
|
563
675
|
for (const rec of pending) {
|
|
564
676
|
if (rec.stoppedAt) continue;
|
|
677
|
+
if (rec._async) continue; // async subagents: parent continues executing, not orphaned
|
|
565
678
|
if (!rec.startedAt) continue;
|
|
566
679
|
const startMs = new Date(rec.startedAt).getTime();
|
|
567
680
|
if (!startMs) continue;
|
|
@@ -587,6 +700,7 @@ async function listAgentsForSession(meta, summary = null) {
|
|
|
587
700
|
await enrichPendingSubagents(meta, recs);
|
|
588
701
|
const out = [];
|
|
589
702
|
for (const rec of recs) {
|
|
703
|
+
if (rec._spawnFailed) continue; // never started — hide from agent log
|
|
590
704
|
let lm = await readSubagentOutput(rec);
|
|
591
705
|
if ((!lm || !lm.trim()) && rec._error) lm = `**Error:**\n\n\`\`\`\n${rec._error}\n\`\`\``;
|
|
592
706
|
out.push(subagentToApi(rec, lm));
|
|
@@ -906,7 +1020,20 @@ async function buildMessages(file, limit = 50, before = null) {
|
|
|
906
1020
|
|
|
907
1021
|
if (m.role === 'user') {
|
|
908
1022
|
const text = flattenContentToText(m.content);
|
|
909
|
-
|
|
1023
|
+
const images = [];
|
|
1024
|
+
if (Array.isArray(m.content)) {
|
|
1025
|
+
m.content.forEach((block, idx) => {
|
|
1026
|
+
if (block && block.type === 'text' && typeof block.text === 'string') {
|
|
1027
|
+
const match = block.text.match(LOCAL_IMAGE_PATH_RE);
|
|
1028
|
+
if (match) images.push({ blockIndex: idx, mediaType: guessMediaTypeFromPath(match[1]), filePath: match[1] });
|
|
1029
|
+
} else if (block && block.type === 'image' && block.source?.type === 'base64') {
|
|
1030
|
+
images.push({ blockIndex: idx, mediaType: block.source.media_type || 'image/png', filePath: null });
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
const entry = { type: 'user', role: 'user', text, timestamp: ts };
|
|
1035
|
+
if (images.length) { entry.images = images; entry.id = e.id; }
|
|
1036
|
+
messages.push(entry);
|
|
910
1037
|
} else if (m.role === 'assistant') {
|
|
911
1038
|
const text = flattenContentToText(m.content);
|
|
912
1039
|
if (text && text.trim()) {
|
|
@@ -980,6 +1107,33 @@ async function buildMessages(file, limit = 50, before = null) {
|
|
|
980
1107
|
return { messages: sliced, hasMore: sliced.length < total, sessionId: null };
|
|
981
1108
|
}
|
|
982
1109
|
|
|
1110
|
+
async function readUserImage(file, msgId, blockIndex) {
|
|
1111
|
+
if (!file || !msgId) return null;
|
|
1112
|
+
const idx = Number(blockIndex);
|
|
1113
|
+
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1114
|
+
const { entries } = await getCachedSession(file);
|
|
1115
|
+
for (const e of entries) {
|
|
1116
|
+
if (e.type !== 'message' || e.id !== msgId) continue;
|
|
1117
|
+
const m = e.message;
|
|
1118
|
+
if (!Array.isArray(m?.content)) return null;
|
|
1119
|
+
const block = m.content[idx];
|
|
1120
|
+
if (!block) return null;
|
|
1121
|
+
if (block.type === 'image' && block.source?.type === 'base64')
|
|
1122
|
+
return { mediaType: block.source.media_type || 'image/png', data: block.source.data };
|
|
1123
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
1124
|
+
const match = block.text.match(LOCAL_IMAGE_PATH_RE);
|
|
1125
|
+
if (match) {
|
|
1126
|
+
try {
|
|
1127
|
+
const buf = await fs.promises.readFile(match[1]);
|
|
1128
|
+
return { mediaType: guessMediaTypeFromPath(match[1]), data: buf.toString('base64') };
|
|
1129
|
+
} catch (_) {}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
983
1137
|
module.exports = {
|
|
984
1138
|
getPiDir,
|
|
985
1139
|
getSessionsDir,
|
|
@@ -997,4 +1151,5 @@ module.exports = {
|
|
|
997
1151
|
setOnBranchResolved,
|
|
998
1152
|
flattenContentToText,
|
|
999
1153
|
invalidateSessionCache,
|
|
1154
|
+
readUserImage,
|
|
1000
1155
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Workspace for the pi coding agent — sessions, todos, subagents, and more.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
"docs:dev": "vitepress dev docs",
|
|
15
15
|
"docs:build": "vitepress build docs",
|
|
16
16
|
"docs:preview": "vitepress preview docs",
|
|
17
|
-
"release:local": "npm version \"$(git describe --tags --exact-match)\" --no-git-tag-version --allow-same-version && npm publish --access public && git checkout -- package.json package-lock.json"
|
|
17
|
+
"release:local": "npm version \"$(git describe --tags --exact-match)\" --no-git-tag-version --allow-same-version && npm publish --access public && git checkout -- package.json package-lock.json",
|
|
18
|
+
"typecheck:extensions": "tsc -p extensions/tsconfig.json --noEmit"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
21
|
+
"@types/node": "^25.6.2",
|
|
22
|
+
"typescript": "^5.8.2",
|
|
20
23
|
"vitepress": "^1.6.4"
|
|
21
24
|
},
|
|
22
25
|
"keywords": [
|
|
@@ -48,10 +51,11 @@
|
|
|
48
51
|
"dependencies": {
|
|
49
52
|
"chokidar": "^3.5.3",
|
|
50
53
|
"express": "^4.18.2",
|
|
51
|
-
"open": "^11.0.0"
|
|
54
|
+
"open": "^11.0.0",
|
|
55
|
+
"typebox": "^1.1.38"
|
|
52
56
|
},
|
|
53
57
|
"peerDependencies": {
|
|
54
|
-
"@
|
|
58
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
55
59
|
},
|
|
56
60
|
"pi": {
|
|
57
61
|
"extensions": [
|
package/public/app.js
CHANGED
|
@@ -4,6 +4,7 @@ let currentSessionId = null;
|
|
|
4
4
|
let currentTasks = [];
|
|
5
5
|
let viewMode = 'session';
|
|
6
6
|
let sessionFilter = 'active';
|
|
7
|
+
let showPins = false; // include pinned sessions when Active filter is active
|
|
7
8
|
let sessionLimit = '20';
|
|
8
9
|
let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
|
|
9
10
|
let recentProjects = new Set();
|
|
@@ -62,6 +63,9 @@ function getUrlState() {
|
|
|
62
63
|
? params.get('messages') === '1'
|
|
63
64
|
: localStorage.getItem('message-panel-open') === 'true',
|
|
64
65
|
projectView: params.get('projectView'),
|
|
66
|
+
pins: params.has('pins')
|
|
67
|
+
? params.get('pins') === '1'
|
|
68
|
+
: localStorage.getItem('show-pins') === '1',
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
|
|
@@ -71,6 +75,7 @@ function updateUrl() {
|
|
|
71
75
|
if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
|
|
72
76
|
if (currentSessionId) params.set('session', currentSessionId);
|
|
73
77
|
if (sessionFilter !== 'active') params.set('filter', sessionFilter);
|
|
78
|
+
if (showPins) params.set('pins', '1');
|
|
74
79
|
if (sessionLimit !== '20') params.set('limit', sessionLimit);
|
|
75
80
|
if (filterProject && filterProject !== '__recent__') params.set('project', filterProject);
|
|
76
81
|
if (searchQuery) params.set('search', searchQuery);
|
|
@@ -107,6 +112,8 @@ function resetState() {
|
|
|
107
112
|
localStorage.removeItem(LAST_VIEW_KEY);
|
|
108
113
|
} catch (_) {}
|
|
109
114
|
sessionFilter = 'active';
|
|
115
|
+
showPins = false;
|
|
116
|
+
localStorage.removeItem('show-pins');
|
|
110
117
|
sessionLimit = '20';
|
|
111
118
|
filterProject = '__recent__';
|
|
112
119
|
searchQuery = '';
|
|
@@ -981,11 +988,19 @@ function renderMessageList(messages) {
|
|
|
981
988
|
</div>`);
|
|
982
989
|
} else {
|
|
983
990
|
const cmd = parseCommandMessage(m.text);
|
|
991
|
+
const imgCount = m.images?.length || 0;
|
|
984
992
|
const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
|
|
985
993
|
const isCmd = !!cmd;
|
|
994
|
+
const chips = [];
|
|
995
|
+
if (imgCount) chips.push(`<span class="user-attach-chip">${imgCount} image${imgCount > 1 ? 's' : ''}</span>`);
|
|
996
|
+
const chipsHtml = chips.length ? `<div class="user-attach-chips">${chips.join('')}</div>` : '';
|
|
997
|
+
let textHtml;
|
|
998
|
+
if (displayText) textHtml = isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText;
|
|
999
|
+
else if (chips.length) textHtml = '<em class="msg-text-muted">(attachment)</em>';
|
|
1000
|
+
else textHtml = '';
|
|
986
1001
|
parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
|
|
987
1002
|
${MSG_ICON_USER}
|
|
988
|
-
<div class="msg-body"><div class="msg-text">${
|
|
1003
|
+
<div class="msg-body"><div class="msg-text">${textHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
|
|
989
1004
|
</div>`);
|
|
990
1005
|
}
|
|
991
1006
|
} else if (m.type === 'assistant') {
|
|
@@ -1373,6 +1388,17 @@ const linkSvg = (size) =>
|
|
|
1373
1388
|
//#endregion
|
|
1374
1389
|
|
|
1375
1390
|
//#region MODALS
|
|
1391
|
+
function renderUserAttachments(m) {
|
|
1392
|
+
if (!m.images?.length || !m.id || !currentSessionId) return '';
|
|
1393
|
+
const imgs = m.images
|
|
1394
|
+
.map((img) => {
|
|
1395
|
+
const url = `/api/sessions/${encodeURIComponent(currentSessionId)}/user-image/${encodeURIComponent(m.id)}/${img.blockIndex}`;
|
|
1396
|
+
return `<img src="${url}" loading="lazy" alt="attached image" class="user-attach-image" onerror="this.style.display='none'" />`;
|
|
1397
|
+
})
|
|
1398
|
+
.join('');
|
|
1399
|
+
return `<div class="user-attach-section"><div class="user-attach-label">Attached images</div><div class="user-attach-images">${imgs}</div></div>`;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1376
1402
|
function showMsgDetail(idx) {
|
|
1377
1403
|
currentMsgDetailIdx = idx;
|
|
1378
1404
|
const m = currentMessages[idx];
|
|
@@ -1439,13 +1465,14 @@ function showMsgDetail(idx) {
|
|
|
1439
1465
|
}
|
|
1440
1466
|
body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs || isParallelAgent ? '' : toolResultHtml) + agentExtraHtml;
|
|
1441
1467
|
} else {
|
|
1442
|
-
|
|
1468
|
+
let rawText = stripAnsi(m.fullText || m.text);
|
|
1469
|
+
const userExtras = renderUserAttachments(m);
|
|
1443
1470
|
const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
|
|
1444
1471
|
document.getElementById('msg-detail-title').textContent =
|
|
1445
1472
|
m.type === 'assistant' ? 'Pi' : m.systemLabel ? 'System' : 'User';
|
|
1446
1473
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1447
1474
|
if (m.compactSummary) {
|
|
1448
|
-
body.innerHTML = renderMarkdown(m.compactSummary);
|
|
1475
|
+
body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
|
|
1449
1476
|
} else if (cmd) {
|
|
1450
1477
|
const argsMatch = rawText.match(/<command-args>([^<]*)<\/command-args>/);
|
|
1451
1478
|
const args = argsMatch?.[1].trim() ? argsMatch[1].trim() : null;
|
|
@@ -1455,9 +1482,9 @@ function showMsgDetail(idx) {
|
|
|
1455
1482
|
.trim();
|
|
1456
1483
|
let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
|
|
1457
1484
|
if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
|
|
1458
|
-
body.innerHTML = cmdHtml;
|
|
1485
|
+
body.innerHTML = cmdHtml + userExtras;
|
|
1459
1486
|
} else {
|
|
1460
|
-
body.innerHTML = renderMarkdown(rawText);
|
|
1487
|
+
body.innerHTML = renderMarkdown(rawText) + userExtras;
|
|
1461
1488
|
}
|
|
1462
1489
|
}
|
|
1463
1490
|
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
@@ -2184,7 +2211,7 @@ function showAgentModal(agentId) {
|
|
|
2184
2211
|
const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
|
|
2185
2212
|
title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
|
|
2186
2213
|
|
|
2187
|
-
const shortModel = agent.model
|
|
2214
|
+
const shortModel = agent.model ?? null;
|
|
2188
2215
|
const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
|
|
2189
2216
|
const chip = (label, value, opts = {}) => {
|
|
2190
2217
|
const cls = opts.cls ? ` ${opts.cls}` : '';
|
|
@@ -2330,7 +2357,7 @@ function _renderSessions() {
|
|
|
2330
2357
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
2331
2358
|
let filteredSessions = sessions;
|
|
2332
2359
|
if (sessionFilter === 'active') {
|
|
2333
|
-
const ACTIVE_PLAN_MS =
|
|
2360
|
+
const ACTIVE_PLAN_MS = 10 * 60 * 1000;
|
|
2334
2361
|
const now = Date.now();
|
|
2335
2362
|
const activeSessionIds = new Set();
|
|
2336
2363
|
filteredSessions = filteredSessions.filter((s) => {
|
|
@@ -2384,15 +2411,15 @@ function _renderSessions() {
|
|
|
2384
2411
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
2385
2412
|
|
|
2386
2413
|
// Re-add pinned/sticky sessions that match the query but were excluded by active filter
|
|
2387
|
-
if (pinnedSessionIds.size > 0 || stickySessionIds.size > 0) {
|
|
2414
|
+
if (showPins && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2388
2415
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2389
2416
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
|
|
2390
2417
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
2391
2418
|
}
|
|
2392
2419
|
}
|
|
2393
2420
|
|
|
2394
|
-
// Include pinned/sticky sessions even if they don't match active
|
|
2395
|
-
if (!searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2421
|
+
// Include pinned/sticky sessions even if they don't match active filter
|
|
2422
|
+
if (sessionFilter === 'active' && showPins && !searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2396
2423
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2397
2424
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
|
|
2398
2425
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
@@ -2605,31 +2632,7 @@ function _renderSessions() {
|
|
|
2605
2632
|
|
|
2606
2633
|
sessionsList.innerHTML = html;
|
|
2607
2634
|
} else {
|
|
2608
|
-
|
|
2609
|
-
const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
|
|
2610
|
-
const rest = filteredSessions.filter(
|
|
2611
|
-
(s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
|
|
2612
|
-
);
|
|
2613
|
-
let html = '';
|
|
2614
|
-
if (sticky.length > 0) {
|
|
2615
|
-
html += sticky.map(renderSessionCard).join('');
|
|
2616
|
-
}
|
|
2617
|
-
const isCollapsed = collapsedProjectGroups.has('__pinned__');
|
|
2618
|
-
const hasPinned = pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id));
|
|
2619
|
-
if (idlePinned.length > 0 || (hasPinned && isCollapsed)) {
|
|
2620
|
-
html += `
|
|
2621
|
-
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
|
|
2622
|
-
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
2623
|
-
<span class="group-name">Pinned</span>
|
|
2624
|
-
<span class="group-count">${idlePinned.length}</span>
|
|
2625
|
-
</div>
|
|
2626
|
-
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
2627
|
-
${idlePinned.map(renderSessionCard).join('')}
|
|
2628
|
-
</div>
|
|
2629
|
-
`;
|
|
2630
|
-
}
|
|
2631
|
-
html += rest.map(renderSessionCard).join('');
|
|
2632
|
-
sessionsList.innerHTML = html;
|
|
2635
|
+
sessionsList.innerHTML = filteredSessions.map(renderSessionCard).join('');
|
|
2633
2636
|
}
|
|
2634
2637
|
|
|
2635
2638
|
const navItems = getNavigableItems();
|
|
@@ -4301,15 +4304,6 @@ function handleSessionOpenEvent(data) {
|
|
|
4301
4304
|
showToast(`Session not found: ${id.slice(0, 8)}`);
|
|
4302
4305
|
return;
|
|
4303
4306
|
}
|
|
4304
|
-
if (sessionFilter !== 'active') {
|
|
4305
|
-
sessionFilter = 'active';
|
|
4306
|
-
const sel = document.getElementById('session-filter');
|
|
4307
|
-
if (sel) sel.value = 'active';
|
|
4308
|
-
updateUrl();
|
|
4309
|
-
}
|
|
4310
|
-
if (!isSessionActive(target)) {
|
|
4311
|
-
stickySessionIds.add(id);
|
|
4312
|
-
}
|
|
4313
4307
|
fetchTasks(id);
|
|
4314
4308
|
}
|
|
4315
4309
|
|
|
@@ -4329,7 +4323,7 @@ async function handlePreviewOpenEvent(data) {
|
|
|
4329
4323
|
if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
|
|
4330
4324
|
renderSessions();
|
|
4331
4325
|
}
|
|
4332
|
-
openPreviewModal(filePath, content);
|
|
4326
|
+
if (!link) openPreviewModal(filePath, content);
|
|
4333
4327
|
if (link) updatePreviewLinkBtn();
|
|
4334
4328
|
}
|
|
4335
4329
|
|
|
@@ -4930,8 +4924,16 @@ function filterBySessions(value) {
|
|
|
4930
4924
|
sessionFilter = value;
|
|
4931
4925
|
updateUrl();
|
|
4932
4926
|
renderSessions();
|
|
4927
|
+
syncFilterUI();
|
|
4933
4928
|
}
|
|
4934
4929
|
|
|
4930
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4931
|
+
function toggleShowPins(checked) {
|
|
4932
|
+
showPins = checked;
|
|
4933
|
+
localStorage.setItem('show-pins', showPins ? '1' : '0');
|
|
4934
|
+
updateUrl();
|
|
4935
|
+
renderSessions();
|
|
4936
|
+
}
|
|
4935
4937
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4936
4938
|
function changeSessionLimit(value) {
|
|
4937
4939
|
sessionLimit = value;
|
|
@@ -5217,7 +5219,8 @@ function initSidebarResize() {
|
|
|
5217
5219
|
});
|
|
5218
5220
|
|
|
5219
5221
|
function onMove(e) {
|
|
5220
|
-
const
|
|
5222
|
+
const maxW = Math.floor(window.innerWidth * 0.9);
|
|
5223
|
+
const w = Math.min(maxW, Math.max(200, startWidth + e.clientX - startX));
|
|
5221
5224
|
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
5222
5225
|
sidebar.style.width = `${w}px`;
|
|
5223
5226
|
}
|
|
@@ -5249,7 +5252,8 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
5249
5252
|
});
|
|
5250
5253
|
|
|
5251
5254
|
function onMove(e) {
|
|
5252
|
-
const
|
|
5255
|
+
const maxW = Math.floor(window.innerWidth * 0.9);
|
|
5256
|
+
const w = Math.min(maxW, Math.max(320, startWidth - (e.clientX - startX)));
|
|
5253
5257
|
panel.style.setProperty(cssVar, `${w}px`);
|
|
5254
5258
|
}
|
|
5255
5259
|
|
|
@@ -5276,9 +5280,16 @@ function loadPanelWidths() {
|
|
|
5276
5280
|
//#endregion
|
|
5277
5281
|
|
|
5278
5282
|
//#region PREFERENCES
|
|
5283
|
+
function syncFilterUI() {
|
|
5284
|
+
const pinsLabel = document.getElementById('show-pins-label');
|
|
5285
|
+
if (pinsLabel) pinsLabel.style.display = sessionFilter === 'active' ? '' : 'none';
|
|
5286
|
+
const pinsChk = document.getElementById('show-pins');
|
|
5287
|
+
if (pinsChk) pinsChk.checked = showPins;
|
|
5288
|
+
}
|
|
5279
5289
|
function loadPreferences() {
|
|
5280
5290
|
document.getElementById('session-filter').value = sessionFilter;
|
|
5281
5291
|
document.getElementById('session-limit').value = sessionLimit;
|
|
5292
|
+
syncFilterUI();
|
|
5282
5293
|
}
|
|
5283
5294
|
|
|
5284
5295
|
//#endregion
|
|
@@ -5789,6 +5800,7 @@ fetch('/api/version')
|
|
|
5789
5800
|
|
|
5790
5801
|
const urlState = getUrlState();
|
|
5791
5802
|
sessionFilter = urlState.filter || 'active';
|
|
5803
|
+
showPins = urlState.pins;
|
|
5792
5804
|
sessionLimit = urlState.limit || '20';
|
|
5793
5805
|
filterProject = urlState.project || '__recent__';
|
|
5794
5806
|
searchQuery = urlState.search || '';
|
|
@@ -5841,6 +5853,7 @@ fetchSessions()
|
|
|
5841
5853
|
window.addEventListener('popstate', () => {
|
|
5842
5854
|
const s = getUrlState();
|
|
5843
5855
|
sessionFilter = s.filter || 'active';
|
|
5856
|
+
showPins = s.pins;
|
|
5844
5857
|
sessionLimit = s.limit || '20';
|
|
5845
5858
|
filterProject = s.project || '__recent__';
|
|
5846
5859
|
searchQuery = s.search || '';
|
package/public/index.html
CHANGED
|
@@ -53,15 +53,15 @@
|
|
|
53
53
|
</div>
|
|
54
54
|
<span class="logo-text">Dashboard</span>
|
|
55
55
|
</div>
|
|
56
|
-
<div id="connection-status" class="connection">
|
|
57
|
-
<span class="connection-dot"></span>
|
|
58
|
-
<span>Connecting</span>
|
|
59
|
-
</div>
|
|
60
56
|
<button id="sidebar-toggle" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar">
|
|
61
57
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
62
58
|
<path d="M15 18l-6-6 6-6"/>
|
|
63
59
|
</svg>
|
|
64
60
|
</button>
|
|
61
|
+
<div id="connection-status" class="connection">
|
|
62
|
+
<span class="connection-dot"></span>
|
|
63
|
+
<span>Connecting</span>
|
|
64
|
+
</div>
|
|
65
65
|
</header>
|
|
66
66
|
|
|
67
67
|
<!-- Tasks -->
|
|
@@ -93,8 +93,15 @@
|
|
|
93
93
|
</select>
|
|
94
94
|
<select id="session-filter" class="filter-dropdown" onchange="filterBySessions(this.value)" aria-label="Filter by session status">
|
|
95
95
|
<option value="all">All Sessions</option>
|
|
96
|
-
<option value="active">Active
|
|
96
|
+
<option value="active">Active</option>
|
|
97
97
|
</select>
|
|
98
|
+
<label class="filter-checkbox-label" id="show-pins-label" title="Include pinned sessions in Active filter">
|
|
99
|
+
<input type="checkbox" id="show-pins" onchange="toggleShowPins(this.checked)" />
|
|
100
|
+
<span class="filter-pins-btn">
|
|
101
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="3"/><path d="M5 10l7-7 7 7"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
|
102
|
+
Pins
|
|
103
|
+
</span>
|
|
104
|
+
</label>
|
|
98
105
|
</div>
|
|
99
106
|
<div class="filter-row">
|
|
100
107
|
<select id="session-limit" class="filter-dropdown" onchange="changeSessionLimit(this.value)" aria-label="Number of sessions to show">
|
package/public/style.css
CHANGED
|
@@ -325,6 +325,7 @@ body::before {
|
|
|
325
325
|
display: flex;
|
|
326
326
|
gap: 6px;
|
|
327
327
|
padding: 0 16px 10px;
|
|
328
|
+
align-items: center;
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
.reset-btn {
|
|
@@ -361,6 +362,49 @@ body::before {
|
|
|
361
362
|
color: var(--text-primary);
|
|
362
363
|
}
|
|
363
364
|
|
|
365
|
+
.filter-checkbox-label {
|
|
366
|
+
display: flex;
|
|
367
|
+
align-items: center;
|
|
368
|
+
gap: 0;
|
|
369
|
+
flex-shrink: 0;
|
|
370
|
+
user-select: none;
|
|
371
|
+
cursor: pointer;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.filter-checkbox-label input[type='checkbox'] {
|
|
375
|
+
position: absolute;
|
|
376
|
+
opacity: 0;
|
|
377
|
+
width: 0;
|
|
378
|
+
height: 0;
|
|
379
|
+
pointer-events: none;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.filter-pins-btn {
|
|
383
|
+
display: flex;
|
|
384
|
+
align-items: center;
|
|
385
|
+
gap: 5px;
|
|
386
|
+
padding: 7px 10px;
|
|
387
|
+
border-radius: 6px;
|
|
388
|
+
border: 1px solid transparent;
|
|
389
|
+
background: var(--bg-deep);
|
|
390
|
+
font-family: var(--mono);
|
|
391
|
+
font-size: 12px;
|
|
392
|
+
color: var(--text-secondary);
|
|
393
|
+
cursor: pointer;
|
|
394
|
+
white-space: nowrap;
|
|
395
|
+
transition: all 0.15s ease;
|
|
396
|
+
line-height: 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.filter-pins-btn:hover {
|
|
400
|
+
border-color: var(--border);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.filter-checkbox-label input[type='checkbox']:checked + .filter-pins-btn {
|
|
404
|
+
border-color: var(--border);
|
|
405
|
+
color: var(--text-primary);
|
|
406
|
+
}
|
|
407
|
+
|
|
364
408
|
.filter-dropdown:focus {
|
|
365
409
|
outline: none;
|
|
366
410
|
border-color: var(--accent);
|
|
@@ -1728,7 +1772,7 @@ body::before {
|
|
|
1728
1772
|
display: flex;
|
|
1729
1773
|
}
|
|
1730
1774
|
.message-panel.msg-expanded-wide {
|
|
1731
|
-
width:
|
|
1775
|
+
width: 90vw;
|
|
1732
1776
|
}
|
|
1733
1777
|
.message-panel-header {
|
|
1734
1778
|
padding: 16px 20px;
|
|
@@ -3758,3 +3802,47 @@ pre.mermaid svg {
|
|
|
3758
3802
|
}
|
|
3759
3803
|
/* #endregion */
|
|
3760
3804
|
|
|
3805
|
+
/* #region USER_ATTACHMENTS */
|
|
3806
|
+
.user-attach-chips {
|
|
3807
|
+
display: flex;
|
|
3808
|
+
gap: 4px;
|
|
3809
|
+
flex-wrap: wrap;
|
|
3810
|
+
margin-top: 4px;
|
|
3811
|
+
}
|
|
3812
|
+
.user-attach-chip {
|
|
3813
|
+
font-size: 10px;
|
|
3814
|
+
padding: 1px 6px;
|
|
3815
|
+
border-radius: 10px;
|
|
3816
|
+
background: var(--bg-hover);
|
|
3817
|
+
color: var(--text-secondary);
|
|
3818
|
+
}
|
|
3819
|
+
.msg-text-muted {
|
|
3820
|
+
color: var(--text-secondary);
|
|
3821
|
+
font-size: 12px;
|
|
3822
|
+
}
|
|
3823
|
+
.user-attach-section {
|
|
3824
|
+
margin-top: 12px;
|
|
3825
|
+
padding-top: 10px;
|
|
3826
|
+
border-top: 1px solid var(--border);
|
|
3827
|
+
}
|
|
3828
|
+
.user-attach-label {
|
|
3829
|
+
font-size: 11px;
|
|
3830
|
+
color: var(--text-secondary);
|
|
3831
|
+
margin-bottom: 6px;
|
|
3832
|
+
text-transform: uppercase;
|
|
3833
|
+
letter-spacing: 0.5px;
|
|
3834
|
+
}
|
|
3835
|
+
.user-attach-images {
|
|
3836
|
+
display: flex;
|
|
3837
|
+
flex-wrap: wrap;
|
|
3838
|
+
gap: 8px;
|
|
3839
|
+
}
|
|
3840
|
+
.user-attach-image {
|
|
3841
|
+
max-width: 100%;
|
|
3842
|
+
max-height: 480px;
|
|
3843
|
+
border-radius: 4px;
|
|
3844
|
+
border: 1px solid var(--border);
|
|
3845
|
+
background: var(--bg-hover);
|
|
3846
|
+
}
|
|
3847
|
+
/* #endregion */
|
|
3848
|
+
|
package/server.js
CHANGED
|
@@ -213,6 +213,21 @@ app.get('/api/sessions/:id/tool-result/:toolUseId', async (req, res) => {
|
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
app.get('/api/sessions/:id/user-image/:msgId/:blockIndex', async (req, res) => {
|
|
217
|
+
const meta = await parsers.findSessionFileById(req.params.id);
|
|
218
|
+
if (!meta?.file) return res.status(404).end();
|
|
219
|
+
try {
|
|
220
|
+
const img = await parsers.readUserImage(meta.file, req.params.msgId, Number(req.params.blockIndex));
|
|
221
|
+
if (!img) return res.status(404).end();
|
|
222
|
+
res.setHeader('Content-Type', img.mediaType);
|
|
223
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
224
|
+
res.end(Buffer.from(img.data, 'base64'));
|
|
225
|
+
} catch (err) {
|
|
226
|
+
res.status(500).end();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
|
|
216
231
|
app.get('/api/sessions/:id/agents', async (req, res) => {
|
|
217
232
|
const meta = await parsers.findSessionFileById(req.params.id);
|
|
218
233
|
if (!meta) return res.status(404).json({ agents: [], error: 'not found' });
|
|
@@ -371,7 +386,7 @@ app.post('/api/preview', async (req, res) => {
|
|
|
371
386
|
const { path: filePath, sessionId, base, link } = req.body || {};
|
|
372
387
|
const abs = resolvePreviewPath(filePath, base);
|
|
373
388
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
374
|
-
const content = await readMarkdownFile(abs);
|
|
389
|
+
const content = link ? null : await readMarkdownFile(abs);
|
|
375
390
|
sseSend({ type: 'preview:open', path: abs, content, sessionId: sessionId || null, link: !!link });
|
|
376
391
|
res.json({ success: true });
|
|
377
392
|
} catch (err) {
|
|
@@ -535,11 +550,38 @@ parsers.setOnBranchResolved((cwd, branch) => {
|
|
|
535
550
|
|
|
536
551
|
const queueSessionUpdate = debounceByKey(75, (sid) => sseSend({ type: 'update', sessionId: sid }));
|
|
537
552
|
|
|
553
|
+
// When a subagent session file changes, notify the parent session so the
|
|
554
|
+
// frontend refreshes agent chips with model info immediately.
|
|
555
|
+
async function notifyParentSession(f) {
|
|
556
|
+
const base = path.basename(f);
|
|
557
|
+
if (base === 'session.jsonl') {
|
|
558
|
+
// Path: .../sessions/<enc>/<sessionName>/<runId>/run-N/session.jsonl
|
|
559
|
+
// Parent session dir is three levels up.
|
|
560
|
+
const parentId = parsers.slugFromFile(path.dirname(path.dirname(path.dirname(f))));
|
|
561
|
+
if (parentId && parentId !== 'session') queueSessionUpdate(parentId);
|
|
562
|
+
} else if (base.endsWith('.jsonl')) {
|
|
563
|
+
// Possible fork-mode sibling session — read first line to check parentSession.
|
|
564
|
+
try {
|
|
565
|
+
const fd = await fsp.open(f, 'r');
|
|
566
|
+
const buf = Buffer.alloc(512);
|
|
567
|
+
const { bytesRead } = await fd.read(buf, 0, 512, 0);
|
|
568
|
+
await fd.close();
|
|
569
|
+
const firstLine = buf.slice(0, bytesRead).toString('utf8').split('\n')[0];
|
|
570
|
+
const ev = JSON.parse(firstLine);
|
|
571
|
+
if (ev.parentSession) {
|
|
572
|
+
const parentId = parsers.slugFromFile(ev.parentSession);
|
|
573
|
+
if (parentId) queueSessionUpdate(parentId);
|
|
574
|
+
}
|
|
575
|
+
} catch {}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
538
579
|
for (const ev of ['add', 'change', 'unlink']) {
|
|
539
580
|
watcher.on(ev, (f) => {
|
|
540
581
|
parsers.invalidateSessionCache(f);
|
|
541
582
|
agentLogStreams.notify(f);
|
|
542
583
|
queueSessionUpdate(parsers.slugFromFile(f));
|
|
584
|
+
notifyParentSession(f).catch(() => {});
|
|
543
585
|
});
|
|
544
586
|
}
|
|
545
587
|
|