pi-kanban 0.1.3 → 0.2.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.
@@ -1,10 +1,11 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { createConnection } from "node:net";
5
5
  import { homedir } from "node:os";
6
6
  import { isAbsolute, join as joinPath, resolve as resolvePath } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { Type } from "typebox";
8
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
10
 
10
11
  const extDir = fileURLToPath(new URL(".", import.meta.url));
@@ -121,6 +122,21 @@ async function postPreview(filePath: string, sessionId: string | null, link = fa
121
122
  });
122
123
  }
123
124
 
125
+ async function postPlan(id: string, planPath: string, title: string | null): Promise<Response> {
126
+ return api("/api/session/plan", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ id, path: planPath, title }),
130
+ });
131
+ }
132
+
133
+ const PLAN_MAX_BYTES = 256 * 1024;
134
+
135
+ function extractFirstHeading(markdown: string): string | null {
136
+ const m = markdown.match(/^#\s+(.+?)\s*$/m);
137
+ return m ? m[1].trim() : null;
138
+ }
139
+
124
140
  function splitArgs(args: string): string[] {
125
141
  return args.trim().split(/\s+/).filter(Boolean);
126
142
  }
@@ -195,6 +211,73 @@ export default function kanbanExtension(pi: ExtensionAPI) {
195
211
  }
196
212
  });
197
213
 
214
+ pi.registerTool({
215
+ name: "bind_plan",
216
+ label: "Bind Plan",
217
+ description:
218
+ "Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
219
+ promptSnippet:
220
+ "Bind a markdown plan file to the current session — call after writing plan file",
221
+ promptGuidelines: [
222
+ "Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
223
+ ],
224
+ parameters: Type.Object({
225
+ path: Type.String({
226
+ description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
227
+ }),
228
+ }),
229
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
230
+ const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
231
+ const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
232
+ const inputPath = String(params.path ?? "").trim();
233
+ if (!inputPath) {
234
+ return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
235
+ }
236
+ const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
237
+
238
+ let title: string | null = null;
239
+ try {
240
+ const stat = statSync(absPath);
241
+ if (!stat.isFile()) {
242
+ return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
243
+ }
244
+ const buf = readFileSync(absPath, { encoding: "utf8" });
245
+ const head = buf.slice(0, PLAN_MAX_BYTES);
246
+ title = extractFirstHeading(head);
247
+ } catch (e: any) {
248
+ return {
249
+ content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
250
+ isError: true,
251
+ };
252
+ }
253
+
254
+ if (!sessionId) {
255
+ return {
256
+ content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
257
+ isError: true,
258
+ };
259
+ }
260
+ try {
261
+ const res = await postPlan(sessionId, absPath, title);
262
+ if (!res.ok) {
263
+ const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
264
+ console.warn(msg);
265
+ return { content: [{ type: "text", text: msg }], isError: true };
266
+ }
267
+ } catch (e: any) {
268
+ const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
269
+ console.warn(msg);
270
+ return { content: [{ type: "text", text: msg }], isError: true };
271
+ }
272
+
273
+ const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
274
+ return {
275
+ content: [{ type: "text", text: summary }],
276
+ details: { path: absPath, title, sessionId },
277
+ };
278
+ },
279
+ });
280
+
198
281
  type Notify = (m: string, l?: "info" | "error") => void;
199
282
 
200
283
  async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
package/lib/pi-parsers.js CHANGED
@@ -233,6 +233,9 @@ function summarize(entries) {
233
233
  target.sessionFile = r.sessionFile || null;
234
234
  target.artifactPaths = r.artifactPaths || null;
235
235
  target.stoppedAt = e.timestamp || null;
236
+ if (r.exitCode != null && r.exitCode !== 0 && !r.error && (r.finalOutput == null || r.finalOutput === '')) {
237
+ target._cancelled = true;
238
+ }
236
239
  });
237
240
  if (m.details.results.length === 0 && pendingArr && pendingArr.length) {
238
241
  const text = Array.isArray(m.content) ? (m.content.find((c) => c && c.type === 'text')?.text || '') : '';
@@ -290,6 +293,9 @@ function summarize(entries) {
290
293
  // pi emits progress events without these fields; their presence marks completion.
291
294
  const isFinal = !!(r.model || r.artifactPaths || r.sessionFile || r.error);
292
295
  if (isFinal) target.stoppedAt = e.timestamp || target.stoppedAt;
296
+ if (isFinal && r.exitCode != null && r.exitCode !== 0 && !r.error && (r.finalOutput == null || r.finalOutput === '')) {
297
+ target._cancelled = true;
298
+ }
293
299
  });
294
300
  }
295
301
  }
@@ -328,15 +334,18 @@ function agentIdFor(rec) {
328
334
 
329
335
  function subagentToApi(rec, lastMessage) {
330
336
  const stopped = !!rec.stoppedAt;
337
+ const cancelled = !!rec._cancelled;
331
338
  let desc = rec.progressSummary
332
339
  ? `${rec.progressSummary.toolCount ?? 0} tools · ${rec.progressSummary.tokens ?? 0} tokens`
333
340
  : null;
334
341
  if (rec._error) desc = `error · ${String(rec._error).split('\n')[0].slice(0, 80)}`;
342
+ else if (cancelled) desc = desc ? `cancelled · ${desc}` : 'cancelled';
335
343
  return {
336
344
  agentId: agentIdFor(rec),
337
345
  type: rec.agent || null,
338
346
  agentName: rec.agent || null,
339
- status: stopped ? 'stopped' : 'active',
347
+ status: stopped ? (cancelled ? 'cancelled' : 'stopped') : 'active',
348
+ cancelled,
340
349
  startedAt: rec.startedAt || null,
341
350
  stoppedAt: rec.stoppedAt || null,
342
351
  updatedAt: rec.stoppedAt || rec.startedAt || null,
@@ -358,7 +367,7 @@ async function readSubagentOutput(rec) {
358
367
  }
359
368
 
360
369
  async function enrichPendingSubagents(meta, subagents) {
361
- let pending = subagents.filter((r) => !r.runId);
370
+ let pending = subagents.filter((r) => !r.runId && !r.stoppedAt);
362
371
  if (pending.length === 0) return;
363
372
  const artifactsDir = path.join(path.dirname(meta.file), 'subagent-artifacts');
364
373
  let files = null;
@@ -403,10 +412,11 @@ async function enrichPendingSubagents(meta, subagents) {
403
412
  };
404
413
  rec.sessionFile = rec.artifactPaths.jsonlPath;
405
414
  rec._error = m.error || null;
415
+ if (m.exitCode != null && m.exitCode !== 0 && !m.error) rec._cancelled = true;
406
416
  if (m.toolCount != null) rec.progressSummary = { toolCount: m.toolCount, tokens: (m.usage?.input || 0) + (m.usage?.output || 0) };
407
417
  }
408
418
 
409
- pending = subagents.filter((r) => !r.runId);
419
+ pending = subagents.filter((r) => !r.runId && !r.stoppedAt);
410
420
  if (pending.length === 0) return;
411
421
  await matchLiveSubagentRuns(meta, pending, subagents);
412
422
  }
@@ -509,13 +519,15 @@ async function findAgentRecord(meta, agentId) {
509
519
  }
510
520
 
511
521
  function tallyTasks(tasks) {
512
- let pending = 0, inProgress = 0, completed = 0;
522
+ let pending = 0, inProgress = 0, completed = 0, total = 0;
513
523
  for (const t of tasks) {
524
+ if (t.status === 'deleted') continue;
525
+ total++;
514
526
  if (t.status === 'completed') completed++;
515
527
  else if (t.status === 'in_progress') inProgress++;
516
528
  else pending++;
517
529
  }
518
- return { taskCount: tasks.length, pending, inProgress, completed };
530
+ return { taskCount: total, pending, inProgress, completed };
519
531
  }
520
532
 
521
533
  const MODEL_CONTEXT_WINDOWS = [
package/lib/task-store.js CHANGED
@@ -86,7 +86,9 @@ function cleanupIfAllCompleted(sessionId) {
86
86
  if (!sessionId) return false;
87
87
  const tasks = listTasks(sessionId);
88
88
  if (!tasks.length) return false;
89
- if (!tasks.every((t) => t.status === 'completed')) return false;
89
+ const live = tasks.filter((t) => t.status !== 'deleted');
90
+ if (!live.length) return false;
91
+ if (!live.every((t) => t.status === 'completed')) return false;
90
92
  const dir = sessionDir(sessionId);
91
93
  for (const t of tasks) {
92
94
  try { fs.unlinkSync(path.join(dir, `${t.id}.json`)); } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kanban",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Workspace for the pi coding agent — sessions, todos, subagents, and more.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -47,7 +47,9 @@
47
47
  "@mariozechner/pi-coding-agent": "*"
48
48
  },
49
49
  "pi": {
50
- "extensions": ["./extensions"],
50
+ "extensions": [
51
+ "./extensions"
52
+ ],
51
53
  "video": "https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-kanban-demo.mp4",
52
54
  "image": "https://raw.githubusercontent.com/NikiforovAll/pi-kanban/main/assets/pi-kanban-poster.png"
53
55
  },
package/public/app.js CHANGED
@@ -181,7 +181,7 @@ async function fetchSessions(includeTasks = true) {
181
181
  }
182
182
  lastSessionsHash = sessionsHash;
183
183
 
184
- sessions = newSessions;
184
+ sessions = newSessions.map(applyStoredPlan);
185
185
  renderSessions();
186
186
  } catch (error) {
187
187
  console.error('Failed to fetch sessions:', error);
@@ -914,7 +914,7 @@ function renderToolItem(m, i, compact) {
914
914
  const toolDetail = getToolDetail(m.tool, m.params, m.detail);
915
915
  const agentLink =
916
916
  m.tool === 'Agent' && m.agentId
917
- ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
917
+ ? `<span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
918
918
  : '';
919
919
  const agentLogBtn = resolveAgentLogBtn(m);
920
920
  const borderStyle = '';
@@ -927,7 +927,7 @@ function renderToolItem(m, i, compact) {
927
927
  const pinBtn = renderMsgPinBtn(m, i);
928
928
  return `<div class="msg-item msg-tool${compactClass}${getTodoStatusClass(m)}" ${itemClickAttr}>
929
929
  ${getToolIcon(m.tool)}
930
- <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
930
+ <div class="msg-body"><div class="msg-text">${agentLink}${escapeHtml(m.tool)}${toolDetail}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
931
931
  </div>`;
932
932
  }
933
933
 
@@ -1952,16 +1952,13 @@ function renderAgentFooter() {
1952
1952
  permHtml +
1953
1953
  visible
1954
1954
  .map((a) => {
1955
+ const isTerminal = a.status === 'stopped' || a.status === 'cancelled';
1955
1956
  const elapsed =
1956
- a.status === 'stopped' && a.stoppedAt
1957
+ isTerminal && a.stoppedAt
1957
1958
  ? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
1958
1959
  : now - new Date(a.startedAt || a.updatedAt).getTime();
1959
- const statusText =
1960
- a.status === 'stopped'
1961
- ? `stopped · ${formatDuration(elapsed)}`
1962
- : a.status === 'idle'
1963
- ? `idle · ${formatDuration(elapsed)}`
1964
- : `active · ${formatDuration(elapsed)}`;
1960
+ const statusLabel = ['stopped', 'cancelled', 'idle'].includes(a.status) ? a.status : 'active';
1961
+ const statusText = `${statusLabel} · ${formatDuration(elapsed)}`;
1965
1962
  const descText = a.description || '';
1966
1963
  const promptTrimmed = stripAnsi((a.prompt || '').trim()).replace(/[\r\n]+/g, ' ');
1967
1964
  const displayText = descText || promptTrimmed;
@@ -2190,7 +2187,7 @@ async function showAllTasks() {
2190
2187
  function renderAllTasks() {
2191
2188
  noSession.style.display = 'none';
2192
2189
  sessionView.classList.add('visible');
2193
- const visibleTasks = currentTasks.filter((t) => !isInternalTask(t));
2190
+ const visibleTasks = currentTasks.filter((t) => !isInternalTask(t) && isLiveTask(t));
2194
2191
  const totalTasks = visibleTasks.length;
2195
2192
  const completed = visibleTasks.filter((t) => t.status === 'completed').length;
2196
2193
  const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
@@ -2569,7 +2566,7 @@ function renderSession() {
2569
2566
 
2570
2567
  // Build meta text with project path and description
2571
2568
  const projectName = session.project ? session.project.split('/').pop() : null;
2572
- const metaParts = [`${currentTasks.length} tasks`];
2569
+ const metaParts = [`${currentTasks.filter(isLiveTask).length} tasks`];
2573
2570
  if (projectName) {
2574
2571
  metaParts.push(projectName);
2575
2572
  }
@@ -2579,12 +2576,13 @@ function renderSession() {
2579
2576
  metaParts.push(formatDate(session.modifiedAt));
2580
2577
  sessionMeta.textContent = metaParts.join(' · ');
2581
2578
 
2582
- const completed = currentTasks.filter((t) => t.status === 'completed').length;
2583
- const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
2579
+ const liveTasks = currentTasks.filter(isLiveTask);
2580
+ const completed = liveTasks.filter((t) => t.status === 'completed').length;
2581
+ const percent = liveTasks.length > 0 ? Math.round((completed / liveTasks.length) * 100) : 0;
2584
2582
 
2585
2583
  progressPercent.textContent = `${percent}%`;
2586
2584
  progressBar.style.width = `${percent}%`;
2587
- const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
2585
+ const hasInProgress = liveTasks.some((t) => t.status === 'in_progress');
2588
2586
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
2589
2587
 
2590
2588
  renderKanban();
@@ -2598,16 +2596,17 @@ function renderProjectView() {
2598
2596
  const folderName = currentProjectPath ? currentProjectPath.split(/[/\\]/).pop() : 'Project';
2599
2597
  sessionTitle.textContent = folderName;
2600
2598
 
2601
- const metaParts = [`${currentProjectSessionIds.length} sessions`, `${currentTasks.length} tasks`];
2599
+ const liveTasks = currentTasks.filter(isLiveTask);
2600
+ const metaParts = [`${currentProjectSessionIds.length} sessions`, `${liveTasks.length} tasks`];
2602
2601
  if (currentProjectPath) metaParts.push(currentProjectPath);
2603
2602
  sessionMeta.textContent = metaParts.join(' · ');
2604
2603
 
2605
- const completed = currentTasks.filter((t) => t.status === 'completed').length;
2606
- const percent = currentTasks.length > 0 ? Math.round((completed / currentTasks.length) * 100) : 0;
2604
+ const completed = liveTasks.filter((t) => t.status === 'completed').length;
2605
+ const percent = liveTasks.length > 0 ? Math.round((completed / liveTasks.length) * 100) : 0;
2607
2606
 
2608
2607
  progressPercent.textContent = `${percent}%`;
2609
2608
  progressBar.style.width = `${percent}%`;
2610
- const hasInProgress = currentTasks.some((t) => t.status === 'in_progress');
2609
+ const hasInProgress = liveTasks.some((t) => t.status === 'in_progress');
2611
2610
  progressBar.classList.toggle('shimmer', hasInProgress && percent < 100);
2612
2611
 
2613
2612
  renderKanban();
@@ -4404,6 +4403,10 @@ function setupEventSource() {
4404
4403
  handleSessionPinEvent(data);
4405
4404
  }
4406
4405
 
4406
+ if (data.type === 'session:plan') {
4407
+ handleSessionPlanEvent(data);
4408
+ }
4409
+
4407
4410
  if (data.type === 'branch:resolved') {
4408
4411
  let changed = false;
4409
4412
  for (const s of sessions) {
@@ -4776,6 +4779,10 @@ function isInternalTask(task) {
4776
4779
  return task.metadata && task.metadata._internal === true;
4777
4780
  }
4778
4781
 
4782
+ function isLiveTask(task) {
4783
+ return task.status !== 'deleted';
4784
+ }
4785
+
4779
4786
  function resolveNamedColor(colorName) {
4780
4787
  if (!colorName) return null;
4781
4788
  return namedColorMap[colorName.toLowerCase()] || null;
@@ -5183,10 +5190,7 @@ async function showSessionInfoModal(sessionId) {
5183
5190
  showInfoModal(session, tasks, planContent);
5184
5191
  };
5185
5192
 
5186
- const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
5187
- .then((r) => (r.ok ? r.json() : null))
5188
- .catch(() => null)
5189
- .then((data) => data?.content || null);
5193
+ const planPromise = fetchPlanContent(sessionId).then((data) => data?.content || null);
5190
5194
 
5191
5195
  const tasksPromise =
5192
5196
  cachedTasks.length > 0
@@ -5375,32 +5379,88 @@ let _planSessionId = null;
5375
5379
 
5376
5380
  //#endregion
5377
5381
 
5382
+ // Plan binding lives in localStorage (key: `kanban:plan:<sessionId>` → {path, title}).
5383
+ // Server is stateless for plan bindings — it only validates + broadcasts via SSE
5384
+ // and serves file content via GET ?path=. The browser is the source of truth.
5385
+ const PLAN_STORAGE_PREFIX = 'kanban:plan:';
5386
+
5387
+ function getStoredPlan(sessionId) {
5388
+ if (!sessionId) return null;
5389
+ try {
5390
+ const raw = localStorage.getItem(PLAN_STORAGE_PREFIX + sessionId);
5391
+ return raw ? JSON.parse(raw) : null;
5392
+ } catch {
5393
+ return null;
5394
+ }
5395
+ }
5396
+
5397
+ function setStoredPlan(sessionId, plan) {
5398
+ if (!sessionId) return;
5399
+ try {
5400
+ if (plan && plan.path) {
5401
+ localStorage.setItem(PLAN_STORAGE_PREFIX + sessionId, JSON.stringify({ path: plan.path, title: plan.title || null }));
5402
+ } else {
5403
+ localStorage.removeItem(PLAN_STORAGE_PREFIX + sessionId);
5404
+ }
5405
+ } catch (e) {
5406
+ console.warn('plan storage write failed:', e);
5407
+ }
5408
+ }
5409
+
5410
+ function applyStoredPlan(session) {
5411
+ if (!session) return session;
5412
+ const stored = getStoredPlan(session.id);
5413
+ if (stored?.path) {
5414
+ session.hasPlan = true;
5415
+ session.planPath = stored.path;
5416
+ session.planTitle = stored.title || session.planTitle || null;
5417
+ }
5418
+ return session;
5419
+ }
5420
+
5421
+ function fetchPlanContent(sessionId) {
5422
+ const stored = getStoredPlan(sessionId);
5423
+ if (!stored?.path) return Promise.resolve(null);
5424
+ const qs = `?path=${encodeURIComponent(stored.path)}`;
5425
+ return fetch(`/api/sessions/${encodeURIComponent(sessionId)}/plan${qs}`)
5426
+ .then((r) => (r.ok ? r.json() : null))
5427
+ .catch(() => null);
5428
+ }
5429
+
5430
+ function handleSessionPlanEvent(data) {
5431
+ if (!data?.id) return;
5432
+ setStoredPlan(data.id, { path: data.path, title: data.title });
5433
+ for (const s of sessions) {
5434
+ if (s.id === data.id) applyStoredPlan(s);
5435
+ }
5436
+ renderSessions();
5437
+ if (_planSessionId === data.id) refreshOpenPlan();
5438
+ if (_infoModalSessionId === data.id) {
5439
+ // Re-run the entry point to pull fresh plan content into the info modal.
5440
+ showSessionInfoModal(data.id).catch(() => {});
5441
+ }
5442
+ }
5443
+
5378
5444
  //#region PLAN
5379
5445
  function refreshOpenPlan() {
5380
5446
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
5381
- fetch(`/api/sessions/${_planSessionId}/plan`)
5382
- .then((r) => (r.ok ? r.json() : null))
5383
- .then((data) => {
5384
- if (data?.content) {
5385
- _pendingPlanContent = data.content;
5386
- const body = document.getElementById('plan-modal-body');
5387
- body.innerHTML = renderMarkdown(_pendingPlanContent);
5388
- }
5389
- })
5390
- .catch(() => {});
5447
+ fetchPlanContent(_planSessionId).then((data) => {
5448
+ if (data?.content) {
5449
+ _pendingPlanContent = data.content;
5450
+ const body = document.getElementById('plan-modal-body');
5451
+ body.innerHTML = renderMarkdown(_pendingPlanContent);
5452
+ }
5453
+ });
5391
5454
  }
5392
5455
 
5393
5456
  function openPlanForSession(sid) {
5394
- fetch(`/api/sessions/${sid}/plan`)
5395
- .then((r) => (r.ok ? r.json() : null))
5396
- .catch(() => null)
5397
- .then((data) => {
5398
- if (data?.content) {
5399
- _pendingPlanContent = data.content;
5400
- _planSessionId = sid;
5401
- openPlanModal();
5402
- }
5403
- });
5457
+ fetchPlanContent(sid).then((data) => {
5458
+ if (data?.content) {
5459
+ _pendingPlanContent = data.content;
5460
+ _planSessionId = sid;
5461
+ openPlanModal();
5462
+ }
5463
+ });
5404
5464
  }
5405
5465
 
5406
5466
  function openPlanModal() {
package/public/style.css CHANGED
@@ -1669,6 +1669,11 @@ body::before {
1669
1669
  color: rgb(34, 160, 80);
1670
1670
  border-color: rgba(34, 197, 94, 0.3);
1671
1671
  }
1672
+ .agent-chip-cancelled {
1673
+ background: rgba(234, 88, 12, 0.15);
1674
+ color: rgb(234, 88, 12);
1675
+ border-color: rgba(234, 88, 12, 0.3);
1676
+ }
1672
1677
  .agent-chip-stopped {
1673
1678
  background: rgba(127, 127, 127, 0.15);
1674
1679
  color: var(--text-tertiary);
@@ -1823,7 +1828,7 @@ body::before {
1823
1828
  .msg-agent-link {
1824
1829
  color: var(--accent);
1825
1830
  font-size: 0.85rem;
1826
- margin-left: 4px;
1831
+ margin-right: 4px;
1827
1832
  opacity: 0.7;
1828
1833
  }
1829
1834
  .msg-agent-link:hover {
@@ -2396,6 +2401,9 @@ body::before {
2396
2401
  .agent-dot.stopped {
2397
2402
  background: var(--text-muted);
2398
2403
  }
2404
+ .agent-dot.cancelled {
2405
+ background: var(--text-muted);
2406
+ }
2399
2407
  .agent-type {
2400
2408
  font-size: 13px;
2401
2409
  font-weight: 500;
package/server.js CHANGED
@@ -206,8 +206,37 @@ app.get('/api/sessions/:id/agents/:agentId/messages', async (req, res) => {
206
206
  }
207
207
  });
208
208
 
209
+ // Plan binding is browser-state: server only validates, broadcasts, and serves
210
+ // file content on demand. The browser is the source of truth for which plan
211
+ // is bound to which session (kept in localStorage), so this server has no
212
+ // in-memory store and no JSONL persistence path here.
213
+ app.get('/api/sessions/:id/plan', async (req, res) => {
214
+ const planPath = typeof req.query.path === 'string' ? req.query.path : '';
215
+ if (!planPath) return res.json({});
216
+ try {
217
+ const stat = await fsp.stat(planPath);
218
+ if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
219
+ const content = await fsp.readFile(planPath, 'utf8');
220
+ res.json({ content, path: planPath });
221
+ } catch (err) {
222
+ res.status(404).json({ error: `plan file not readable: ${planPath}` });
223
+ }
224
+ });
225
+
226
+ app.post('/api/session/plan', async (req, res) => {
227
+ const { id, path: planPath, title } = req.body || {};
228
+ if (!id || !planPath) return res.status(400).json({ error: 'id and path required' });
229
+ try {
230
+ const stat = await fsp.stat(planPath);
231
+ if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
232
+ } catch {
233
+ return res.status(404).json({ error: `plan file not found: ${planPath}` });
234
+ }
235
+ sseSend({ type: 'session:plan', id, path: planPath, title: title || null });
236
+ res.json({ ok: true });
237
+ });
238
+
209
239
  // Stubbed endpoints (later phases may fill these).
210
- app.get('/api/sessions/:id/plan', emptyObj);
211
240
  app.get('/api/tasks/all', async (_req, res) => {
212
241
  try { res.json(await allStoredTasks()); }
213
242
  catch (err) { res.status(500).json({ error: String(err) }); }
@@ -344,6 +373,20 @@ app.post('/api/open-in-editor', (req, res) => {
344
373
  res.status(500).json({ error: String(err) });
345
374
  }
346
375
  });
376
+ app.post('/api/open-folder', (req, res) => {
377
+ try {
378
+ const { folder, file } = req.body || {};
379
+ const targets = [];
380
+ if (folder) targets.push(folder);
381
+ if (file) targets.push(file);
382
+ if (!targets.length) return res.status(400).json({ error: 'folder or file required' });
383
+ openInEditor(...targets);
384
+ res.json({ success: true });
385
+ } catch (err) {
386
+ res.status(500).json({ error: String(err) });
387
+ }
388
+ });
389
+
347
390
  app.post('/api/sessions/:id/agents/:agentId/stop', (_req, res) => res.json({ ok: true }));
348
391
 
349
392
  // SSE — broadcast file changes as session-changed events.