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 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 | web | app | pin | sticky-pin | unpin | preview | link` from inside pi.
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
 
@@ -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 "@mariozechner/pi-coding-agent";
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
- "web",
65
- "app",
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
- "preview",
70
- "link",
74
+ "view-doc",
75
+ "link-doc",
71
76
  ] as const;
72
- type Sub = (typeof SUBCOMMANDS)[number];
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 or /kanban web`);
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 or /kanban web to launch it`);
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 | web | app | pin | sticky-pin | unpin | preview | link",
348
- getArgumentCompletions: (prefix) =>
349
- SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s })),
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
- if (!(await ensureRunning(notify))) return;
382
- const id = rest[0] ?? ctx.sessionManager.getSessionId() ?? null;
383
- if (!id) {
384
- notify("Usage: /kanban open <session-id> (no current session to default to)", "error");
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
- const res = await postSessionOpen(id);
388
- if (!res.ok) {
389
- notify(`open failed (${res.status}): ${await res.text()}`, "error");
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
- const usedCurrent = !rest[0];
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 === "web") {
398
- const { default: open } = await import("open");
399
- await open(url);
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
- if (sub === "pin" || sub === "sticky-pin" || sub === "unpin") {
416
- if (!(await ensureRunning(notify))) return;
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
- const PIN_STATE = { "sticky-pin": "sticky", "unpin": "none", "pin": "pinned" } as const;
423
- const state = PIN_STATE[sub];
424
- const res = await postPin(id, state);
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
- const file = rest[0];
438
- if (!file) {
439
- notify(`Usage: /kanban ${sub} <file.md> [session-id]`, "error");
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
- const sessionId = rest[1] ?? ctx.sessionManager.getSessionId() ?? null;
443
- if (sub === "link" && !sessionId) {
444
- notify("Usage: /kanban link <file.md> <session-id> (no current session to default to)", "error");
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
- const expanded =
448
- file === "~" || file.startsWith("~/") || file.startsWith("~\\")
449
- ? joinPath(homedir(), file.slice(1))
450
- : file;
451
- const abs = isAbsolute(expanded) ? expanded : resolvePath(process.cwd(), expanded);
452
- const res = await postPreview(abs, sessionId, sub === "link");
453
- if (!res.ok) {
454
- notify(`${sub} failed (${res.status}): ${await res.text()}`, "error");
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
- notify(`Unknown subcommand: ${sub}. Try: ${SUBCOMMANDS.join(", ")}`, "error");
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
- const text = Array.isArray(m.content) ? (m.content.find((c) => c && c.type === 'text')?.text || '') : '';
315
- const cancelled = /cancel/i.test(text);
316
- for (const rec of pendingArr) {
317
- rec.stoppedAt = e.timestamp || null;
318
- if (cancelled) rec._error = rec._error || 'Chain cancelled';
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 raw = await fs.promises.readFile(jsonl, 'utf8');
518
- const lines = raw.split('\n').filter(Boolean).slice(0, 10);
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
- messages.push({ type: 'user', role: 'user', text, timestamp: ts });
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.1",
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
- "@mariozechner/pi-coding-agent": "*"
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">${isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
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
- const rawText = stripAnsi(m.fullText || m.text);
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 ? agent.model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : null;
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 = 15 * 60 * 1000;
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/recent filter
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
- const sticky = filteredSessions.filter((s) => isPlacedSticky(s.id));
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 w = Math.min(600, Math.max(200, startWidth + e.clientX - startX));
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 w = Math.min(900, Math.max(320, startWidth - (e.clientX - startX)));
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 Only</option>
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: 60vw;
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