pi-kanban 0.1.1 → 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.
- package/extensions/kanban.ts +84 -1
- package/lib/pi-parsers.js +17 -5
- package/lib/task-store.js +3 -1
- package/package.json +5 -3
- package/public/app.js +103 -43
- package/public/style.css +9 -1
- package/server.js +44 -1
package/extensions/kanban.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { createConnection } from "node:net";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { isAbsolute, join as joinPath, resolve as resolvePath } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Type } from "typebox";
|
|
8
9
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
10
|
|
|
10
11
|
const extDir = fileURLToPath(new URL(".", import.meta.url));
|
|
@@ -121,6 +122,21 @@ async function postPreview(filePath: string, sessionId: string | null, link = fa
|
|
|
121
122
|
});
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
async function postPlan(id: string, planPath: string, title: string | null): Promise<Response> {
|
|
126
|
+
return api("/api/session/plan", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ id, path: planPath, title }),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PLAN_MAX_BYTES = 256 * 1024;
|
|
134
|
+
|
|
135
|
+
function extractFirstHeading(markdown: string): string | null {
|
|
136
|
+
const m = markdown.match(/^#\s+(.+?)\s*$/m);
|
|
137
|
+
return m ? m[1].trim() : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
function splitArgs(args: string): string[] {
|
|
125
141
|
return args.trim().split(/\s+/).filter(Boolean);
|
|
126
142
|
}
|
|
@@ -195,6 +211,73 @@ export default function kanbanExtension(pi: ExtensionAPI) {
|
|
|
195
211
|
}
|
|
196
212
|
});
|
|
197
213
|
|
|
214
|
+
pi.registerTool({
|
|
215
|
+
name: "bind_plan",
|
|
216
|
+
label: "Bind Plan",
|
|
217
|
+
description:
|
|
218
|
+
"Attach a markdown plan file (e.g. plan.md) to the current session so the dashboard can render it.",
|
|
219
|
+
promptSnippet:
|
|
220
|
+
"Bind a markdown plan file to the current session — call after writing plan file",
|
|
221
|
+
promptGuidelines: [
|
|
222
|
+
"Use bind_plan once a planning markdown file exists so it surfaces in pi-kanban.",
|
|
223
|
+
],
|
|
224
|
+
parameters: Type.Object({
|
|
225
|
+
path: Type.String({
|
|
226
|
+
description: "Path to the plan markdown file. Absolute, or relative to the session cwd.",
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
230
|
+
const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null;
|
|
231
|
+
const cwd = ctx?.sessionManager?.getCwd?.() ?? process.cwd();
|
|
232
|
+
const inputPath = String(params.path ?? "").trim();
|
|
233
|
+
if (!inputPath) {
|
|
234
|
+
return { content: [{ type: "text", text: "Error: path is required." }], isError: true };
|
|
235
|
+
}
|
|
236
|
+
const absPath = isAbsolute(inputPath) ? inputPath : resolvePath(cwd, inputPath);
|
|
237
|
+
|
|
238
|
+
let title: string | null = null;
|
|
239
|
+
try {
|
|
240
|
+
const stat = statSync(absPath);
|
|
241
|
+
if (!stat.isFile()) {
|
|
242
|
+
return { content: [{ type: "text", text: `Error: ${absPath} is not a file.` }], isError: true };
|
|
243
|
+
}
|
|
244
|
+
const buf = readFileSync(absPath, { encoding: "utf8" });
|
|
245
|
+
const head = buf.slice(0, PLAN_MAX_BYTES);
|
|
246
|
+
title = extractFirstHeading(head);
|
|
247
|
+
} catch (e: any) {
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: "text", text: `Error reading ${absPath}: ${e?.message ?? e}` }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!sessionId) {
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text", text: "Error: no active session id; cannot bind plan." }],
|
|
257
|
+
isError: true,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const res = await postPlan(sessionId, absPath, title);
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
const msg = `pi-kanban server rejected bind_plan: HTTP ${res.status}. Is /kanban running?`;
|
|
264
|
+
console.warn(msg);
|
|
265
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
266
|
+
}
|
|
267
|
+
} catch (e: any) {
|
|
268
|
+
const msg = `pi-kanban: bind_plan POST failed (server may be stopped): ${e?.message ?? e}`;
|
|
269
|
+
console.warn(msg);
|
|
270
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const summary = title ? `Bound plan "${title}" → ${absPath}` : `Bound plan → ${absPath}`;
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text", text: summary }],
|
|
276
|
+
details: { path: absPath, title, sessionId },
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
198
281
|
type Notify = (m: string, l?: "info" | "error") => void;
|
|
199
282
|
|
|
200
283
|
async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
|
package/lib/pi-parsers.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-kanban",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Workspace for the pi coding agent — sessions, todos, subagents, and more.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pi-kanban": "./server.js"
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"@mariozechner/pi-coding-agent": "*"
|
|
48
48
|
},
|
|
49
49
|
"pi": {
|
|
50
|
-
"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
|
-
?
|
|
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}
|
|
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
|
-
|
|
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
|
|
1960
|
-
|
|
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
|
|
2583
|
-
const
|
|
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 =
|
|
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
|
|
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 =
|
|
2606
|
-
const percent =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
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
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
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-
|
|
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.
|