claude-code-kanban 3.2.3 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +458 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +52 -0
- package/public/app.js +299 -4
- package/public/index.html +27 -0
- package/public/style.css +71 -0
- package/server.js +127 -11
package/cli.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
4
|
+
const COMMANDS = {
|
|
5
|
+
preview: {
|
|
6
|
+
summary: 'Open a markdown file in the preview modal on connected browser tabs',
|
|
7
|
+
usage: 'claude-code-kanban preview <file.md> [--session <id>]',
|
|
8
|
+
flags: {
|
|
9
|
+
'--session <id>': 'Switch focused session in the browser (does not link the file)',
|
|
10
|
+
},
|
|
11
|
+
run: runPreviewCli,
|
|
12
|
+
},
|
|
13
|
+
session: {
|
|
14
|
+
summary: 'List or open Claude Code sessions',
|
|
15
|
+
verbs: {
|
|
16
|
+
list: {
|
|
17
|
+
summary: 'List sessions',
|
|
18
|
+
usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
|
|
19
|
+
flags: {
|
|
20
|
+
'--active': 'Only sessions with recent activity (sidebar-style filter)',
|
|
21
|
+
'--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
|
|
22
|
+
'--project <name>': 'Filter by project name (substring match)',
|
|
23
|
+
'--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
|
|
24
|
+
'--json': 'Output JSON instead of a table',
|
|
25
|
+
},
|
|
26
|
+
run: runSessionListCli,
|
|
27
|
+
},
|
|
28
|
+
open: {
|
|
29
|
+
summary: 'Focus a session in the browser (Active tab)',
|
|
30
|
+
usage: 'claude-code-kanban session open <id>',
|
|
31
|
+
flags: {
|
|
32
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
33
|
+
},
|
|
34
|
+
run: runSessionOpenCli,
|
|
35
|
+
},
|
|
36
|
+
view: {
|
|
37
|
+
summary: 'Show full session stats (metadata + context window + cost)',
|
|
38
|
+
usage: 'claude-code-kanban session view <id> [--json]',
|
|
39
|
+
flags: {
|
|
40
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
41
|
+
'--json': 'Output JSON instead of formatted sections',
|
|
42
|
+
},
|
|
43
|
+
run: runSessionViewCli,
|
|
44
|
+
},
|
|
45
|
+
peek: {
|
|
46
|
+
summary: 'Show the last N messages from a session',
|
|
47
|
+
usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
|
|
48
|
+
flags: {
|
|
49
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
50
|
+
'--limit <n>': 'Number of messages (default: 10, max: 50)',
|
|
51
|
+
'--json': 'Output JSON instead of formatted lines',
|
|
52
|
+
},
|
|
53
|
+
run: runSessionPeekCli,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function runCli(argv) {
|
|
60
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
61
|
+
console.log(require('./package.json').version);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
const cli = resolveCliCommand(argv);
|
|
65
|
+
if (cli.kind === 'server') return false;
|
|
66
|
+
if (cli.kind === 'help') {
|
|
67
|
+
if (cli.target && Object.hasOwn(COMMANDS, cli.target)) printNounHelp(cli.target);
|
|
68
|
+
else printTopHelp();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
if (cli.kind === 'unknown-noun') {
|
|
72
|
+
console.error(`Unknown command: ${cli.noun}\n`);
|
|
73
|
+
printTopHelp();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
if (cli.kind === 'unknown-verb') {
|
|
77
|
+
console.error(`Unknown subcommand: ${cli.noun} ${cli.verb}\n`);
|
|
78
|
+
printNounHelp(cli.noun);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
if (cli.kind === 'noun') {
|
|
82
|
+
printNounHelp(cli.noun);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
if (cli.kind === 'leaf') {
|
|
86
|
+
if (cli.args.includes('--help') || cli.args.includes('-h')) {
|
|
87
|
+
printLeafHelp(cli.name, cli.entry);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
cli.entry.run(cli.args)
|
|
91
|
+
.then(code => { process.exitCode = code; })
|
|
92
|
+
.catch(e => { console.error(e.message); process.exitCode = 1; });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveCliCommand(argv) {
|
|
99
|
+
const noun = argv[2] && !argv[2].startsWith('-') ? argv[2] : null;
|
|
100
|
+
const hasHelp = (a) => a.includes('--help') || a.includes('-h');
|
|
101
|
+
if (!noun) return hasHelp(argv) ? { kind: 'help' } : { kind: 'server' };
|
|
102
|
+
if (noun === 'help') return { kind: 'help', target: argv[3] };
|
|
103
|
+
if (!Object.hasOwn(COMMANDS, noun)) return { kind: 'unknown-noun', noun };
|
|
104
|
+
const entry = COMMANDS[noun];
|
|
105
|
+
if (!entry.verbs) return { kind: 'leaf', name: noun, entry, args: argv.slice(3) };
|
|
106
|
+
const verb = argv[3] && !argv[3].startsWith('-') ? argv[3] : null;
|
|
107
|
+
if (!verb) return { kind: 'noun', noun };
|
|
108
|
+
if (!Object.hasOwn(entry.verbs, verb)) return { kind: 'unknown-verb', noun, verb };
|
|
109
|
+
return { kind: 'leaf', name: `${noun} ${verb}`, entry: entry.verbs[verb], args: argv.slice(4) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function printTopHelp() {
|
|
113
|
+
console.log('Usage: claude-code-kanban <command> [args] [--flags]\n');
|
|
114
|
+
console.log('Commands:');
|
|
115
|
+
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
|
116
|
+
console.log(` ${name.padEnd(20)}${cmd.summary}`);
|
|
117
|
+
if (cmd.verbs) {
|
|
118
|
+
for (const [vName, v] of Object.entries(cmd.verbs)) {
|
|
119
|
+
console.log(` ${`${name} ${vName}`.padEnd(18)}${v.summary}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log(` ${'help'.padEnd(20)}Show help for a command (claude-code-kanban help <command>)`);
|
|
124
|
+
console.log('\nFlags:');
|
|
125
|
+
console.log(' --help, -h Show help (top-level, noun-level, or leaf-level)');
|
|
126
|
+
console.log(' --version, -v Print version and exit');
|
|
127
|
+
console.log('\nServer mode (no subcommand):');
|
|
128
|
+
console.log(' --port <n> Port to listen on (default 3456)');
|
|
129
|
+
console.log(' --dir <path> Override Claude config dir (default ~/.claude)');
|
|
130
|
+
console.log(' --open Open browser on start');
|
|
131
|
+
console.log(' --install, --uninstall Install or remove the agent-spy hook');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function printNounHelp(noun) {
|
|
135
|
+
const entry = COMMANDS[noun];
|
|
136
|
+
console.log(`${entry.summary}\n`);
|
|
137
|
+
if (entry.verbs) {
|
|
138
|
+
console.log(`Usage: claude-code-kanban ${noun} <subcommand> [args] [--flags]\n`);
|
|
139
|
+
console.log('Subcommands:');
|
|
140
|
+
for (const [vName, v] of Object.entries(entry.verbs)) {
|
|
141
|
+
console.log(` ${vName.padEnd(12)}${v.summary}`);
|
|
142
|
+
}
|
|
143
|
+
console.log(`\nRun \`claude-code-kanban ${noun} <subcommand> --help\` for details.`);
|
|
144
|
+
} else {
|
|
145
|
+
printLeafHelp(noun, entry);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printLeafHelp(name, entry) {
|
|
150
|
+
console.log(`${entry.summary}\n`);
|
|
151
|
+
console.log(`Usage: ${entry.usage}`);
|
|
152
|
+
if (entry.flags && Object.keys(entry.flags).length) {
|
|
153
|
+
const pad = Math.max(...Object.keys(entry.flags).map(f => f.length));
|
|
154
|
+
console.log('\nFlags:');
|
|
155
|
+
for (const [flag, desc] of Object.entries(entry.flags)) {
|
|
156
|
+
console.log(` ${flag.padEnd(pad + 2)}${desc}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.log('\n --help, -h Show this help');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getArgValue(args, name) {
|
|
163
|
+
const idx = args.findIndex(a => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
164
|
+
if (idx === -1) return null;
|
|
165
|
+
const arg = args[idx];
|
|
166
|
+
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
|
|
167
|
+
return args[idx + 1] && !args[idx + 1].startsWith('--') ? args[idx + 1] : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cliPort() { return process.env.PORT || 3456; }
|
|
171
|
+
function unreachable() { return `Cannot reach cck server on port ${cliPort()}. Start it first with "claude-code-kanban".`; }
|
|
172
|
+
|
|
173
|
+
class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
|
|
174
|
+
|
|
175
|
+
async function cliFetch(urlPath, init) {
|
|
176
|
+
try {
|
|
177
|
+
return await fetch(`http://127.0.0.1:${cliPort()}${urlPath}`, init);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function reportCliError(e) {
|
|
185
|
+
console.error(e.code === 'unreachable' ? e.message : (e.message || String(e)));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runPreviewCli(args) {
|
|
189
|
+
const filePathArg = args.find(a => !a.startsWith('--'));
|
|
190
|
+
if (!filePathArg) {
|
|
191
|
+
printLeafHelp('preview', COMMANDS.preview);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
|
|
195
|
+
const abs = path.resolve(filePathArg);
|
|
196
|
+
try {
|
|
197
|
+
const res = await cliFetch('/api/preview', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({ path: abs, sessionId })
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
console.error(`Preview failed (${res.status}): ${await res.text()}`);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId})` : ''}`);
|
|
207
|
+
return 0;
|
|
208
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Mirror of `isSessionActive` in public/app.js — keep in sync (different runtimes, no shared module).
|
|
212
|
+
function isSessionActive(s) {
|
|
213
|
+
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sessionStatus(s) {
|
|
217
|
+
if (!isSessionActive(s)) return 'idle';
|
|
218
|
+
if (s.hasWaitingForUser) return 'wait';
|
|
219
|
+
if (s.inProgress > 0) return 'busy';
|
|
220
|
+
return 'active';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseLimit(args, { fallback, allowAll = false }) {
|
|
224
|
+
const raw = getArgValue(args, 'limit');
|
|
225
|
+
if (raw === null) return { ok: true, limit: fallback };
|
|
226
|
+
if (allowAll && raw === 'all') return { ok: true, limit: null };
|
|
227
|
+
const n = parseInt(raw, 10);
|
|
228
|
+
if (Number.isNaN(n) || n <= 0) return { ok: false, error: `Invalid --limit value: ${raw}` };
|
|
229
|
+
return { ok: true, limit: n };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchSessionsList(limit) {
|
|
233
|
+
const q = limit === null ? 'all' : String(limit);
|
|
234
|
+
const res = await cliFetch(`/api/sessions?limit=${q}`);
|
|
235
|
+
if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
|
|
236
|
+
return res.json();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function resolveSessionByIdOrPrefix(idArg) {
|
|
240
|
+
let res;
|
|
241
|
+
try {
|
|
242
|
+
res = await cliFetch(`/api/session/resolve?id=${encodeURIComponent(idArg)}`);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
reportCliError(e);
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (res.status === 404) {
|
|
248
|
+
console.error(`No session matches: ${idArg}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (res.status === 409) {
|
|
252
|
+
const { matches = [] } = await res.json().catch(() => ({}));
|
|
253
|
+
console.error(`Ambiguous prefix "${idArg}" matches ${matches.length} sessions:`);
|
|
254
|
+
for (const m of matches.slice(0, 10)) console.error(` ${m.id} ${m.customTitle || ''}`);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
console.error(`Resolve failed (${res.status}): ${await res.text()}`);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return res.json();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runSessionListCli(args) {
|
|
265
|
+
const activeOnly = args.includes('--active');
|
|
266
|
+
const projectFilter = getArgValue(args, 'project');
|
|
267
|
+
const daysArg = getArgValue(args, 'days');
|
|
268
|
+
const days = daysArg !== null ? parseFloat(daysArg) : null;
|
|
269
|
+
if (daysArg !== null && (Number.isNaN(days) || days <= 0)) {
|
|
270
|
+
console.error(`Invalid --days value: ${daysArg}`);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
const parsed = parseLimit(args, { fallback: 10, allowAll: true });
|
|
274
|
+
if (!parsed.ok) { console.error(parsed.error); return 1; }
|
|
275
|
+
const limit = parsed.limit;
|
|
276
|
+
const asJson = args.includes('--json');
|
|
277
|
+
const hasClientFilter = activeOnly || days !== null || projectFilter;
|
|
278
|
+
let list;
|
|
279
|
+
try {
|
|
280
|
+
list = await fetchSessionsList(hasClientFilter ? null : limit);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
reportCliError(e);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
if (activeOnly) list = list.filter(isSessionActive);
|
|
286
|
+
if (days !== null) {
|
|
287
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
288
|
+
list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
|
|
289
|
+
}
|
|
290
|
+
if (projectFilter) {
|
|
291
|
+
const needle = projectFilter.toLowerCase();
|
|
292
|
+
list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
|
|
293
|
+
}
|
|
294
|
+
const totalMatched = list.length;
|
|
295
|
+
if (limit !== null && list.length > limit) list = list.slice(0, limit);
|
|
296
|
+
if (asJson) {
|
|
297
|
+
console.log(JSON.stringify(list, null, 2));
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
if (!list.length) {
|
|
301
|
+
console.log('No sessions match.');
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
const rows = list.map(s => ({
|
|
305
|
+
id: s.id.slice(0, 8),
|
|
306
|
+
status: sessionStatus(s),
|
|
307
|
+
age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
|
|
308
|
+
tasks: `${s.completed}/${s.taskCount}`,
|
|
309
|
+
project: path.basename(s.project || ''),
|
|
310
|
+
title: s.customTitle || s.name || s.slug || '',
|
|
311
|
+
}));
|
|
312
|
+
const w = {
|
|
313
|
+
id: 8,
|
|
314
|
+
status: Math.max(6, ...rows.map(r => r.status.length)),
|
|
315
|
+
age: Math.max(3, ...rows.map(r => r.age.length)),
|
|
316
|
+
tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
|
|
317
|
+
project: Math.max(7, ...rows.map(r => r.project.length)),
|
|
318
|
+
};
|
|
319
|
+
console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
|
|
320
|
+
for (const r of rows) {
|
|
321
|
+
console.log(`${r.id.padEnd(w.id)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
|
|
322
|
+
}
|
|
323
|
+
if (limit !== null && totalMatched > limit) {
|
|
324
|
+
console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
|
|
325
|
+
}
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function formatAge(ms) {
|
|
330
|
+
if (ms < 0) return '0s';
|
|
331
|
+
const s = Math.floor(ms / 1000);
|
|
332
|
+
if (s < 60) return `${s}s`;
|
|
333
|
+
const m = Math.floor(s / 60);
|
|
334
|
+
if (m < 60) return `${m}m`;
|
|
335
|
+
const h = Math.floor(m / 60);
|
|
336
|
+
if (h < 24) return `${h}h`;
|
|
337
|
+
const d = Math.floor(h / 24);
|
|
338
|
+
return `${d}d`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function runSessionOpenCli(args) {
|
|
342
|
+
const idArg = args.find(a => !a.startsWith('--'));
|
|
343
|
+
if (!idArg) {
|
|
344
|
+
printLeafHelp('session open', COMMANDS.session.verbs.open);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
348
|
+
if (!resolved) return 1;
|
|
349
|
+
try {
|
|
350
|
+
const res = await cliFetch('/api/session/open', {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({ id: resolved.id })
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
console.error(`Open failed (${res.status}): ${await res.text()}`);
|
|
357
|
+
return 1;
|
|
358
|
+
}
|
|
359
|
+
console.log(`Session opened: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
|
|
360
|
+
return 0;
|
|
361
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runSessionViewCli(args) {
|
|
365
|
+
const idArg = args.find(a => !a.startsWith('--'));
|
|
366
|
+
if (!idArg) {
|
|
367
|
+
printLeafHelp('session view', COMMANDS.session.verbs.view);
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
const asJson = args.includes('--json');
|
|
371
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
372
|
+
if (!resolved) return 1;
|
|
373
|
+
let list;
|
|
374
|
+
try {
|
|
375
|
+
list = await fetchSessionsList(null);
|
|
376
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
377
|
+
const s = list.find(x => x.id === resolved.id);
|
|
378
|
+
if (!s) {
|
|
379
|
+
console.error(`Session ${resolved.id} not found in /api/sessions response.`);
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
if (asJson) {
|
|
383
|
+
console.log(JSON.stringify(s, null, 2));
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
const status = sessionStatus(s);
|
|
387
|
+
const title = s.customTitle || s.name || s.slug || '';
|
|
388
|
+
const age = s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-';
|
|
389
|
+
const fmtTok = (n) => typeof n === 'number' ? (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) : '-';
|
|
390
|
+
const fmtCost = (n) => typeof n === 'number' ? `$${n.toFixed(2)}` : '-';
|
|
391
|
+
const lines = [];
|
|
392
|
+
lines.push(`${s.id.slice(0, 8)} — ${title} [${status}]`);
|
|
393
|
+
if (s.project) lines.push(` ${path.basename(s.project)}${s.gitBranch ? ` · ${s.gitBranch}` : ''} · modified ${age} ago`);
|
|
394
|
+
lines.push(` Tasks: ${s.completed}/${s.taskCount}${s.inProgress ? ` (${s.inProgress} in progress)` : ''}${s.pending ? ` · ${s.pending} pending` : ''}`);
|
|
395
|
+
const ctx = s.contextStatus;
|
|
396
|
+
if (ctx) {
|
|
397
|
+
const cw = ctx.context_window || {};
|
|
398
|
+
const cost = ctx.cost || {};
|
|
399
|
+
const rl = ctx.rate_limits || {};
|
|
400
|
+
const modelName = ctx.model?.display_name || ctx.model?.id || '-';
|
|
401
|
+
const modelExtras = [
|
|
402
|
+
ctx.effort?.level,
|
|
403
|
+
ctx.thinking?.enabled ? 'thinking' : null,
|
|
404
|
+
ctx.fast_mode ? 'fast' : null,
|
|
405
|
+
].filter(Boolean).join(' · ');
|
|
406
|
+
lines.push(` Model: ${modelName}${modelExtras ? ` (${modelExtras})` : ''}`);
|
|
407
|
+
if (cw.used_percentage != null) {
|
|
408
|
+
lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in / ${fmtTok(cw.total_output_tokens)} out · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
|
|
409
|
+
}
|
|
410
|
+
lines.push(` Cost: ${fmtCost(cost.total_cost_usd)} · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${cost.total_duration_ms != null ? formatAge(cost.total_duration_ms) : '-'} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`);
|
|
411
|
+
if (rl.five_hour || rl.seven_day) {
|
|
412
|
+
lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
console.log(lines.join('\n'));
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function runSessionPeekCli(args) {
|
|
420
|
+
const idArg = args.find(a => !a.startsWith('--'));
|
|
421
|
+
if (!idArg) {
|
|
422
|
+
printLeafHelp('session peek', COMMANDS.session.verbs.peek);
|
|
423
|
+
return 1;
|
|
424
|
+
}
|
|
425
|
+
const parsed = parseLimit(args, { fallback: 10 });
|
|
426
|
+
if (!parsed.ok) { console.error(parsed.error); return 1; }
|
|
427
|
+
const limit = parsed.limit;
|
|
428
|
+
const asJson = args.includes('--json');
|
|
429
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
430
|
+
if (!resolved) return 1;
|
|
431
|
+
try {
|
|
432
|
+
const res = await cliFetch(`/api/sessions/${resolved.id}/messages?limit=${Math.min(limit, 50)}`);
|
|
433
|
+
if (!res.ok) {
|
|
434
|
+
console.error(`Peek failed (${res.status}): ${await res.text()}`);
|
|
435
|
+
return 1;
|
|
436
|
+
}
|
|
437
|
+
const { messages } = await res.json();
|
|
438
|
+
const ordered = [...messages].reverse();
|
|
439
|
+
if (asJson) {
|
|
440
|
+
console.log(JSON.stringify(ordered, null, 2));
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
if (!ordered.length) {
|
|
444
|
+
console.log(`No messages for session ${resolved.id.slice(0, 8)}.`);
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
console.log(`Session ${resolved.id.slice(0, 8)}${resolved.customTitle ? ` — ${resolved.customTitle}` : ''}`);
|
|
448
|
+
for (const m of ordered) {
|
|
449
|
+
const ts = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('en-GB', { hour12: false }) : '--:--:--';
|
|
450
|
+
const label = (m.type === 'tool_use' ? (m.tool || 'tool') : m.type).padEnd(10);
|
|
451
|
+
const body = (m.text || m.detail || m.description || '').replace(/\s+/g, ' ').trim();
|
|
452
|
+
console.log(`[${ts}] ${label} ${body.slice(0, 120)}${body.length > 120 ? '…' : ''}`);
|
|
453
|
+
}
|
|
454
|
+
return 0;
|
|
455
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
module.exports = { runCli };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"server.js",
|
|
47
|
+
"cli.js",
|
|
47
48
|
"install.js",
|
|
48
49
|
"plugin/**/*",
|
|
49
50
|
"lib/**/*",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kanban
|
|
3
|
+
description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
|
|
4
|
+
compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Kanban Skill
|
|
8
|
+
|
|
9
|
+
Drive the kanban from this Claude session. Every command corresponds to something the user could do in the kanban dashboard.
|
|
10
|
+
|
|
11
|
+
The current Claude session id is available as `${CLAUDE_SESSION_ID}` (substituted when this skill loads), so the user never needs to look it up.
|
|
12
|
+
|
|
13
|
+
NOTE, sometimes user prefers `npx claude-code-kanban` to `claude-code-kanban` — both work, as long as the CLI is available. Use npx as fallback or when user instructed explicitly.
|
|
14
|
+
|
|
15
|
+
## Open the current session in kanban
|
|
16
|
+
|
|
17
|
+
Primary use case. Pins the active Claude session in the kanban sidebar and switches to the Active tab.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Trigger phrases: "show this session in kanban", "focus current session", "open in kanban".
|
|
24
|
+
|
|
25
|
+
## Preview a file in kanban
|
|
26
|
+
|
|
27
|
+
Opens a markdown file in the preview modal:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude-code-kanban preview <path-to-file.md> --session ${CLAUDE_SESSION_ID} # prefer this one
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Relative paths are fine — the server resolves to absolute.
|
|
34
|
+
|
|
35
|
+
## Inspect sessions (read-only)
|
|
36
|
+
|
|
37
|
+
When the user asks "what's going on in kanban?" or wants stats:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
claude-code-kanban session list --active # recent active sessions
|
|
41
|
+
claude-code-kanban session list --project <name> # filter by project
|
|
42
|
+
claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats for current session
|
|
43
|
+
claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Add `--json` to any list-style verb for machine-readable output.
|
|
47
|
+
|
|
48
|
+
## Troubleshooting
|
|
49
|
+
|
|
50
|
+
- **"Cannot reach cck server on port 3456"** → ask the user to start it: `claude-code-kanban` (or `npm start` in the cck repo).
|
|
51
|
+
- **Different port** → set `PORT=<n>` env var when invoking the CLI.
|
|
52
|
+
- **Ambiguous session prefix (HTTP 409)** → use the full id. With `${CLAUDE_SESSION_ID}` this won't happen.
|
package/public/app.js
CHANGED
|
@@ -135,7 +135,11 @@ async function fetchSessions(includeTasks = true) {
|
|
|
135
135
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
136
136
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
137
137
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
138
|
-
const
|
|
138
|
+
const projectParam =
|
|
139
|
+
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
140
|
+
const sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}`).then((r) =>
|
|
141
|
+
r.json(),
|
|
142
|
+
);
|
|
139
143
|
|
|
140
144
|
let newSessions, newTasks;
|
|
141
145
|
if (includeTasks) {
|
|
@@ -4036,6 +4040,219 @@ document.addEventListener('keydown', (e) => {
|
|
|
4036
4040
|
|
|
4037
4041
|
//#endregion
|
|
4038
4042
|
|
|
4043
|
+
//#region MARKDOWN_PREVIEW
|
|
4044
|
+
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
4045
|
+
let currentPreviewPath = null;
|
|
4046
|
+
|
|
4047
|
+
function getSessionPreviewPaths(sessionId) {
|
|
4048
|
+
if (!sessionId) return [];
|
|
4049
|
+
try {
|
|
4050
|
+
const raw = localStorage.getItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
4051
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
4052
|
+
return Array.isArray(arr) ? arr : [];
|
|
4053
|
+
} catch {
|
|
4054
|
+
return [];
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
function addSessionPreviewPath(sessionId, filePath) {
|
|
4059
|
+
if (!sessionId || !filePath) return;
|
|
4060
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
4061
|
+
paths.unshift(filePath);
|
|
4062
|
+
localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
function removeSessionPreviewPath(sessionId, filePath) {
|
|
4066
|
+
if (!sessionId) return;
|
|
4067
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => p !== filePath);
|
|
4068
|
+
if (paths.length) localStorage.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
4069
|
+
else localStorage.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
function splitFrontmatter(text) {
|
|
4073
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
4074
|
+
if (!m) return { fm: null, body: text };
|
|
4075
|
+
const fm = {};
|
|
4076
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
4077
|
+
const kv = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
|
|
4078
|
+
if (kv) fm[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
|
|
4079
|
+
}
|
|
4080
|
+
return { fm, body: m[2] };
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
function renderFrontmatterBlock(fm) {
|
|
4084
|
+
const rows = Object.entries(fm)
|
|
4085
|
+
.map(
|
|
4086
|
+
([k, v]) =>
|
|
4087
|
+
`<div class="fm-row"><span class="fm-k">${escapeHtml(k)}</span><span class="fm-v">${escapeHtml(String(v))}</span></div>`,
|
|
4088
|
+
)
|
|
4089
|
+
.join('');
|
|
4090
|
+
return `<details class="preview-fm" open><summary>frontmatter</summary><div class="fm-grid">${rows}</div></details>`;
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
function openPreviewModal(filePath, content) {
|
|
4094
|
+
currentPreviewPath = filePath;
|
|
4095
|
+
document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
|
|
4096
|
+
const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
|
|
4097
|
+
document.getElementById('preview-modal-body').innerHTML =
|
|
4098
|
+
(fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
4099
|
+
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
4100
|
+
document.getElementById('preview-modal').classList.add('visible');
|
|
4101
|
+
updatePreviewLinkBtn();
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
function isPreviewLinkedToCurrentSession() {
|
|
4105
|
+
if (!currentPreviewPath || !currentSessionId) return false;
|
|
4106
|
+
return getSessionPreviewPaths(currentSessionId).includes(currentPreviewPath);
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
function updatePreviewLinkBtn() {
|
|
4110
|
+
const btn = document.getElementById('preview-link-btn');
|
|
4111
|
+
if (!btn) return;
|
|
4112
|
+
if (!currentSessionId) {
|
|
4113
|
+
btn.style.display = 'none';
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
btn.style.display = '';
|
|
4117
|
+
const linked = isPreviewLinkedToCurrentSession();
|
|
4118
|
+
btn.title = linked ? 'Unlink from current session' : 'Link to current session';
|
|
4119
|
+
btn.style.color = linked ? 'var(--accent, #5b9a6b)' : '';
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4123
|
+
function togglePreviewSessionLink() {
|
|
4124
|
+
if (!currentPreviewPath || !currentSessionId) {
|
|
4125
|
+
showToast('Select a session first');
|
|
4126
|
+
return;
|
|
4127
|
+
}
|
|
4128
|
+
if (isPreviewLinkedToCurrentSession()) {
|
|
4129
|
+
removeSessionPreviewPath(currentSessionId, currentPreviewPath);
|
|
4130
|
+
showToast('Unlinked from session');
|
|
4131
|
+
} else {
|
|
4132
|
+
addSessionPreviewPath(currentSessionId, currentPreviewPath);
|
|
4133
|
+
showToast('Linked to session');
|
|
4134
|
+
}
|
|
4135
|
+
updatePreviewLinkBtn();
|
|
4136
|
+
if (_infoModalSessionId === currentSessionId) {
|
|
4137
|
+
refreshInfoModalLinkedDocs();
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
function refreshInfoModalLinkedDocs() {
|
|
4142
|
+
const bodyEl = document.getElementById('team-modal-body');
|
|
4143
|
+
if (!bodyEl) return;
|
|
4144
|
+
const existing = bodyEl.querySelector('.linked-docs-section');
|
|
4145
|
+
const html = renderLinkedDocsHtml(_infoModalSessionId);
|
|
4146
|
+
if (!existing) {
|
|
4147
|
+
if (!html) return;
|
|
4148
|
+
const planCard = bodyEl.querySelector('[data-plan-card]');
|
|
4149
|
+
const wrap = document.createElement('div');
|
|
4150
|
+
wrap.innerHTML = html;
|
|
4151
|
+
const node = wrap.firstElementChild;
|
|
4152
|
+
if (planCard?.nextSibling) planCard.parentNode.insertBefore(node, planCard.nextSibling);
|
|
4153
|
+
else bodyEl.appendChild(node);
|
|
4154
|
+
bindLinkedDocsHandlers(node, _infoModalSessionId);
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
if (!html) {
|
|
4158
|
+
existing.remove();
|
|
4159
|
+
return;
|
|
4160
|
+
}
|
|
4161
|
+
const wrap = document.createElement('div');
|
|
4162
|
+
wrap.innerHTML = html;
|
|
4163
|
+
const node = wrap.firstElementChild;
|
|
4164
|
+
existing.replaceWith(node);
|
|
4165
|
+
bindLinkedDocsHandlers(node, _infoModalSessionId);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4169
|
+
function closePreviewModal() {
|
|
4170
|
+
resetModalFullscreen('preview-modal');
|
|
4171
|
+
currentPreviewPath = null;
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4175
|
+
function openPreviewInEditor() {
|
|
4176
|
+
if (!currentPreviewPath) return;
|
|
4177
|
+
postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
async function openPreviewByPath(filePath) {
|
|
4181
|
+
if (!filePath) return;
|
|
4182
|
+
try {
|
|
4183
|
+
const r = await fetch(`/api/preview?path=${encodeURIComponent(filePath)}`);
|
|
4184
|
+
if (!r.ok) {
|
|
4185
|
+
showToast('Preview file unavailable');
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
const data = await r.json();
|
|
4189
|
+
openPreviewModal(data.path, data.content);
|
|
4190
|
+
} catch {
|
|
4191
|
+
showToast('Failed to load preview');
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
function handleSessionOpenEvent(data) {
|
|
4196
|
+
const { id } = data;
|
|
4197
|
+
if (!id) return;
|
|
4198
|
+
const target = sessions.find((s) => s.id === id);
|
|
4199
|
+
if (!target) {
|
|
4200
|
+
showToast(`Session not found: ${id.slice(0, 8)}`);
|
|
4201
|
+
return;
|
|
4202
|
+
}
|
|
4203
|
+
if (sessionFilter !== 'active') {
|
|
4204
|
+
sessionFilter = 'active';
|
|
4205
|
+
const sel = document.getElementById('session-filter');
|
|
4206
|
+
if (sel) sel.value = 'active';
|
|
4207
|
+
updateUrl();
|
|
4208
|
+
}
|
|
4209
|
+
if (!isSessionActive(target)) {
|
|
4210
|
+
stickySessionIds.add(id);
|
|
4211
|
+
}
|
|
4212
|
+
fetchTasks(id);
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
function handlePreviewOpenEvent(data) {
|
|
4216
|
+
const { path: filePath, content, sessionId } = data;
|
|
4217
|
+
if (sessionId && sessionId !== currentSessionId) {
|
|
4218
|
+
if (sessions.find((s) => s.id === sessionId)) {
|
|
4219
|
+
fetchTasks(sessionId);
|
|
4220
|
+
} else {
|
|
4221
|
+
showToast(`Preview received for unknown session ${sessionId.slice(0, 8)}`);
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
openPreviewModal(filePath, content);
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
function renderLinkedDocsHtml(sessionId) {
|
|
4228
|
+
const paths = getSessionPreviewPaths(sessionId);
|
|
4229
|
+
if (!paths.length) return '';
|
|
4230
|
+
const items = paths
|
|
4231
|
+
.map((p, i) => {
|
|
4232
|
+
const name = p.split(/[\\/]/).pop();
|
|
4233
|
+
return `<a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}" style="color:var(--accent-text);text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;">${escapeHtml(name)}</a>`;
|
|
4234
|
+
})
|
|
4235
|
+
.join(', ');
|
|
4236
|
+
return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
|
|
4237
|
+
<div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Linked documents</div>
|
|
4238
|
+
<div>${items}</div>
|
|
4239
|
+
</div>`;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
function bindLinkedDocsHandlers(container, sessionId) {
|
|
4243
|
+
if (!container) return;
|
|
4244
|
+
const links = container.querySelectorAll('.linked-doc-link');
|
|
4245
|
+
if (!links.length) return;
|
|
4246
|
+
const paths = getSessionPreviewPaths(sessionId);
|
|
4247
|
+
for (const link of links) {
|
|
4248
|
+
link.addEventListener('click', (e) => {
|
|
4249
|
+
e.preventDefault();
|
|
4250
|
+
openPreviewByPath(paths[+link.dataset.idx]);
|
|
4251
|
+
});
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
//#endregion
|
|
4255
|
+
|
|
4039
4256
|
//#region SSE
|
|
4040
4257
|
function setupEventSource() {
|
|
4041
4258
|
let retryDelay = 1000;
|
|
@@ -4149,6 +4366,15 @@ function setupEventSource() {
|
|
|
4149
4366
|
|
|
4150
4367
|
if (data.type === 'context-update') {
|
|
4151
4368
|
debouncedRefresh(data.sessionId, true);
|
|
4369
|
+
refreshRateLimits();
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
if (data.type === 'preview:open') {
|
|
4373
|
+
handlePreviewOpenEvent(data);
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
if (data.type === 'session:open') {
|
|
4377
|
+
handleSessionOpenEvent(data);
|
|
4152
4378
|
}
|
|
4153
4379
|
|
|
4154
4380
|
if (data.type === 'team-update') {
|
|
@@ -4590,7 +4816,7 @@ document.addEventListener('click', (e) => {
|
|
|
4590
4816
|
function filterByProject(project) {
|
|
4591
4817
|
filterProject = project || null;
|
|
4592
4818
|
updateUrl();
|
|
4593
|
-
|
|
4819
|
+
fetchSessions(false);
|
|
4594
4820
|
showAllTasks();
|
|
4595
4821
|
}
|
|
4596
4822
|
|
|
@@ -4936,7 +5162,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
4936
5162
|
_pendingPlanContent = planContent;
|
|
4937
5163
|
const titleMatch = planContent.match(/^#\s+(.+)$/m);
|
|
4938
5164
|
const planTitle = titleMatch ? titleMatch[1].trim() : null;
|
|
4939
|
-
html += `<div onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
|
|
5165
|
+
html += `<div data-plan-card="1" onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
|
|
4940
5166
|
<span style="font-size: 14px;">📋</span>
|
|
4941
5167
|
<div style="flex: 1; min-width: 0;">
|
|
4942
5168
|
<div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
|
|
@@ -4946,6 +5172,8 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
4946
5172
|
</div>`;
|
|
4947
5173
|
}
|
|
4948
5174
|
|
|
5175
|
+
html += renderLinkedDocsHtml(session.id);
|
|
5176
|
+
|
|
4949
5177
|
// Team info section
|
|
4950
5178
|
if (teamConfig) {
|
|
4951
5179
|
const ownerCounts = {};
|
|
@@ -5000,6 +5228,7 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
|
|
|
5000
5228
|
}
|
|
5001
5229
|
|
|
5002
5230
|
bodyEl.innerHTML = html;
|
|
5231
|
+
bindLinkedDocsHandlers(bodyEl, session.id);
|
|
5003
5232
|
const alreadyVisible = modal.classList.contains('visible');
|
|
5004
5233
|
_infoModalSessionId = session.id;
|
|
5005
5234
|
updateStickyBtnState();
|
|
@@ -5298,10 +5527,76 @@ msgContentEl.addEventListener('wheel', function (e) {
|
|
|
5298
5527
|
}
|
|
5299
5528
|
});
|
|
5300
5529
|
|
|
5530
|
+
const footerState = { version: null, limitsKey: null, timer: null };
|
|
5531
|
+
function formatResetIn(epochSec) {
|
|
5532
|
+
if (!epochSec) return null;
|
|
5533
|
+
const ms = epochSec * 1000 - Date.now();
|
|
5534
|
+
if (ms <= 0) return 'now';
|
|
5535
|
+
const m = Math.round(ms / 60000);
|
|
5536
|
+
if (m < 60) return `${m}m`;
|
|
5537
|
+
const h = Math.floor(m / 60);
|
|
5538
|
+
const rm = m % 60;
|
|
5539
|
+
if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`;
|
|
5540
|
+
const d = Math.floor(h / 24);
|
|
5541
|
+
const rh = h % 24;
|
|
5542
|
+
return rh ? `${d}d ${rh}h` : `${d}d`;
|
|
5543
|
+
}
|
|
5544
|
+
function makeLimitCell(label, bucket) {
|
|
5545
|
+
const pct = bucket?.used_percentage;
|
|
5546
|
+
const cell = document.createElement('span');
|
|
5547
|
+
cell.className = 'footer-limit-cell';
|
|
5548
|
+
const reset = formatResetIn(bucket?.resets_at);
|
|
5549
|
+
if (reset) cell.title = `${label}: resets in ${reset}`;
|
|
5550
|
+
cell.append(document.createTextNode(`${label} `));
|
|
5551
|
+
const strong = document.createElement('strong');
|
|
5552
|
+
strong.textContent = pct == null ? '-%' : `${Math.ceil(pct)}%`;
|
|
5553
|
+
cell.appendChild(strong);
|
|
5554
|
+
return cell;
|
|
5555
|
+
}
|
|
5556
|
+
function makeLimitSpan(rl) {
|
|
5557
|
+
const span = document.createElement('span');
|
|
5558
|
+
span.className = 'footer-limits';
|
|
5559
|
+
span.append(makeLimitCell('5h', rl?.five_hour), document.createTextNode(' · '), makeLimitCell('7d', rl?.seven_day));
|
|
5560
|
+
return span;
|
|
5561
|
+
}
|
|
5562
|
+
function renderSidebarFooter(rateLimits) {
|
|
5563
|
+
const el = document.getElementById('sidebar-footer');
|
|
5564
|
+
if (!el) return;
|
|
5565
|
+
const fh = rateLimits?.five_hour?.used_percentage ?? null;
|
|
5566
|
+
const sd = rateLimits?.seven_day?.used_percentage ?? null;
|
|
5567
|
+
const children = [];
|
|
5568
|
+
if (footerState.version) {
|
|
5569
|
+
const v = document.createElement('span');
|
|
5570
|
+
v.textContent = `v${footerState.version}`;
|
|
5571
|
+
children.push(v);
|
|
5572
|
+
}
|
|
5573
|
+
if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
|
|
5574
|
+
el.replaceChildren(...children);
|
|
5575
|
+
}
|
|
5576
|
+
function refreshRateLimits() {
|
|
5577
|
+
if (footerState.timer) return;
|
|
5578
|
+
footerState.timer = setTimeout(() => {
|
|
5579
|
+
footerState.timer = null;
|
|
5580
|
+
fetch('/api/context-status')
|
|
5581
|
+
.then((r) => r.json())
|
|
5582
|
+
.then((all) => {
|
|
5583
|
+
const rl = Object.values(all || {}).find((e) => e?.rate_limits)?.rate_limits || null;
|
|
5584
|
+
const fh = rl?.five_hour?.used_percentage ?? null;
|
|
5585
|
+
const sd = rl?.seven_day?.used_percentage ?? null;
|
|
5586
|
+
const key = `${fh}|${sd}`;
|
|
5587
|
+
if (key === footerState.limitsKey) return;
|
|
5588
|
+
footerState.limitsKey = key;
|
|
5589
|
+
renderSidebarFooter(rl);
|
|
5590
|
+
})
|
|
5591
|
+
.catch(() => {});
|
|
5592
|
+
}, 1500);
|
|
5593
|
+
}
|
|
5301
5594
|
fetch('/api/version')
|
|
5302
5595
|
.then((r) => r.json())
|
|
5303
5596
|
.then((d) => {
|
|
5304
|
-
|
|
5597
|
+
footerState.version = d.version;
|
|
5598
|
+
renderSidebarFooter(null);
|
|
5599
|
+
refreshRateLimits();
|
|
5305
5600
|
})
|
|
5306
5601
|
.catch(() => {});
|
|
5307
5602
|
|
package/public/index.html
CHANGED
|
@@ -310,6 +310,33 @@
|
|
|
310
310
|
</div>
|
|
311
311
|
</div>
|
|
312
312
|
|
|
313
|
+
<!-- Markdown Preview Modal -->
|
|
314
|
+
<div id="preview-modal" class="modal-overlay" style="z-index:10002;" onclick="closePreviewModal()">
|
|
315
|
+
<div class="modal preview-modal-dialog" onclick="event.stopPropagation()">
|
|
316
|
+
<div class="modal-header">
|
|
317
|
+
<h3 class="modal-title" id="preview-modal-title">Preview</h3>
|
|
318
|
+
<div style="display:flex;gap:4px;align-items:center;">
|
|
319
|
+
<button id="preview-link-btn" class="icon-btn" aria-label="Link to current session" title="Link to current session" onclick="togglePreviewSessionLink()">
|
|
320
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
321
|
+
</button>
|
|
322
|
+
<button class="icon-btn" aria-label="Open in editor" title="Open in editor" onclick="openPreviewInEditor()">
|
|
323
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
324
|
+
</button>
|
|
325
|
+
<button id="preview-modal-fullscreen-btn" class="icon-btn" aria-label="Toggle fullscreen" title="Toggle fullscreen" onclick="toggleModalFullscreen('preview-modal')">
|
|
326
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
327
|
+
</button>
|
|
328
|
+
<button class="modal-close" aria-label="Close" onclick="closePreviewModal()">
|
|
329
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="modal-body" style="flex: 0 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden;">
|
|
334
|
+
<div id="preview-modal-body" class="rendered-md" style="word-break: break-word; font-size: 13px; line-height: 1.6; overflow-y: auto; flex: 0 1 auto; min-height: 0; padding-right: 8px;"></div>
|
|
335
|
+
<div id="preview-modal-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted); flex-shrink: 0;"></div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
313
340
|
<!-- Help Modal -->
|
|
314
341
|
<div id="help-modal" class="modal-overlay" onclick="closeHelpModal()">
|
|
315
342
|
<div class="modal" onclick="event.stopPropagation()">
|
package/public/style.css
CHANGED
|
@@ -739,6 +739,10 @@ body::before {
|
|
|
739
739
|
background-position: top;
|
|
740
740
|
font-size: 10px;
|
|
741
741
|
color: var(--text-muted);
|
|
742
|
+
display: flex;
|
|
743
|
+
align-items: center;
|
|
744
|
+
justify-content: space-between;
|
|
745
|
+
gap: 8px;
|
|
742
746
|
}
|
|
743
747
|
|
|
744
748
|
.sidebar-footer a {
|
|
@@ -747,6 +751,11 @@ body::before {
|
|
|
747
751
|
transition: color 0.15s;
|
|
748
752
|
}
|
|
749
753
|
|
|
754
|
+
.sidebar-footer .footer-limits strong {
|
|
755
|
+
color: var(--text-secondary);
|
|
756
|
+
font-weight: 600;
|
|
757
|
+
}
|
|
758
|
+
|
|
750
759
|
.sidebar-footer a:hover {
|
|
751
760
|
color: var(--text-secondary);
|
|
752
761
|
}
|
|
@@ -2689,6 +2698,68 @@ body.light .msg-assistant .msg-text {
|
|
|
2689
2698
|
max-height: 92vh;
|
|
2690
2699
|
}
|
|
2691
2700
|
|
|
2701
|
+
.modal.preview-modal-dialog {
|
|
2702
|
+
width: 90vw;
|
|
2703
|
+
max-width: 1200px;
|
|
2704
|
+
max-height: 90vh;
|
|
2705
|
+
display: flex;
|
|
2706
|
+
flex-direction: column;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
.modal.preview-modal-dialog.fullscreen {
|
|
2710
|
+
width: 90vw;
|
|
2711
|
+
max-width: 90vw;
|
|
2712
|
+
height: 90vh;
|
|
2713
|
+
max-height: 90vh;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
.preview-fm {
|
|
2717
|
+
margin: 0 0 14px;
|
|
2718
|
+
border: 1px solid var(--border);
|
|
2719
|
+
border-radius: 6px;
|
|
2720
|
+
background: var(--bg-elevated, rgba(127, 127, 127, 0.06));
|
|
2721
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
2722
|
+
font-size: 11px;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
.preview-fm > summary {
|
|
2726
|
+
cursor: pointer;
|
|
2727
|
+
padding: 6px 10px;
|
|
2728
|
+
color: var(--text-muted);
|
|
2729
|
+
text-transform: uppercase;
|
|
2730
|
+
letter-spacing: 0.06em;
|
|
2731
|
+
font-size: 10px;
|
|
2732
|
+
user-select: none;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
.preview-fm[open] > summary {
|
|
2736
|
+
border-bottom: 1px solid var(--border);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
.preview-fm .fm-grid {
|
|
2740
|
+
padding: 8px 10px;
|
|
2741
|
+
display: grid;
|
|
2742
|
+
gap: 4px 12px;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
.preview-fm .fm-row {
|
|
2746
|
+
display: grid;
|
|
2747
|
+
grid-template-columns: minmax(80px, 140px) 1fr;
|
|
2748
|
+
gap: 12px;
|
|
2749
|
+
align-items: baseline;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
.preview-fm .fm-k {
|
|
2753
|
+
color: var(--accent, #7aa2f7);
|
|
2754
|
+
font-weight: 600;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
.preview-fm .fm-v {
|
|
2758
|
+
color: var(--text, inherit);
|
|
2759
|
+
word-break: break-word;
|
|
2760
|
+
white-space: pre-wrap;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2692
2763
|
.modal-sm {
|
|
2693
2764
|
max-width: 440px;
|
|
2694
2765
|
}
|
package/server.js
CHANGED
|
@@ -20,14 +20,15 @@ const {
|
|
|
20
20
|
extractPromptFromTranscript
|
|
21
21
|
} = require('./lib/parsers');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const { runInstall, runUninstall } = require('./install');
|
|
27
|
-
(process.argv.includes('--install') ? runInstall() : runUninstall())
|
|
23
|
+
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
24
|
+
const { runInstall, runUninstall } = require("./install");
|
|
25
|
+
(process.argv.includes("--install") ? runInstall() : runUninstall())
|
|
28
26
|
.then(() => process.exit(0))
|
|
29
27
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
28
|
+
return;
|
|
30
29
|
}
|
|
30
|
+
if (require("./cli").runCli(process.argv)) return;
|
|
31
|
+
|
|
31
32
|
|
|
32
33
|
const app = express();
|
|
33
34
|
const PORT = process.env.PORT || 3456;
|
|
@@ -387,9 +388,23 @@ function loadSessionMetadata() {
|
|
|
387
388
|
resolvedProjectPath = sessionInfo.projectPath;
|
|
388
389
|
}
|
|
389
390
|
|
|
391
|
+
const candidateProject = indexProjectPath || sessionInfo.projectPath || null;
|
|
392
|
+
const existing = metadata[sessionId];
|
|
393
|
+
// Same sessionId can appear in multiple project dirs (e.g. "shadow"
|
|
394
|
+
// JSONLs that only hold custom-title/agent-name records when a session
|
|
395
|
+
// is continued from a worktree). Don't let a weaker entry (no cwd, no
|
|
396
|
+
// project) overwrite a previously resolved one — just merge scalars.
|
|
397
|
+
if (existing && existing.project && !candidateProject) {
|
|
398
|
+
if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
|
|
399
|
+
if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
|
|
400
|
+
if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
|
|
401
|
+
sessionIds.push(sessionId);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
390
405
|
metadata[sessionId] = {
|
|
391
406
|
slug: sessionInfo.slug,
|
|
392
|
-
project:
|
|
407
|
+
project: candidateProject,
|
|
393
408
|
cwd: sessionInfo.cwd || null,
|
|
394
409
|
gitBranch: sessionInfo.gitBranch || null,
|
|
395
410
|
customTitle: sessionInfo.customTitle || null,
|
|
@@ -777,6 +792,12 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
777
792
|
let sessions = Array.from(sessionsMap.values());
|
|
778
793
|
sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
|
|
779
794
|
|
|
795
|
+
// Apply project filter before limit so the limit is per-project
|
|
796
|
+
const projectFilter = req.query.project;
|
|
797
|
+
if (projectFilter) {
|
|
798
|
+
sessions = sessions.filter(s => s.project === projectFilter);
|
|
799
|
+
}
|
|
800
|
+
|
|
780
801
|
// Apply limit if specified, but always include pinned sessions
|
|
781
802
|
if (limit !== null && limit > 0) {
|
|
782
803
|
const top = sessions.slice(0, limit);
|
|
@@ -1289,6 +1310,7 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
1289
1310
|
}
|
|
1290
1311
|
|
|
1291
1312
|
const metadata = loadSessionMetadata();
|
|
1313
|
+
const { listToSessions } = loadAllTaskMaps();
|
|
1292
1314
|
const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
|
|
1293
1315
|
.filter(d => d.isDirectory());
|
|
1294
1316
|
|
|
@@ -1299,6 +1321,19 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
1299
1321
|
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
|
|
1300
1322
|
const meta = metadata[sessionDir.name] || {};
|
|
1301
1323
|
|
|
1324
|
+
// For custom task list directories (non-UUID dirs), resolve project from the
|
|
1325
|
+
// mapped sessions since those dirs don't have their own metadata entry.
|
|
1326
|
+
let project = meta.project || null;
|
|
1327
|
+
if (!project) {
|
|
1328
|
+
const mappedSessions = listToSessions[sessionDir.name];
|
|
1329
|
+
if (mappedSessions) {
|
|
1330
|
+
for (const [sid, info] of Object.entries(mappedSessions)) {
|
|
1331
|
+
project = metadata[sid]?.project || info.project || null;
|
|
1332
|
+
if (project) break;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1302
1337
|
for (const file of taskFiles) {
|
|
1303
1338
|
try {
|
|
1304
1339
|
const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
|
|
@@ -1306,7 +1341,7 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
1306
1341
|
...task,
|
|
1307
1342
|
sessionId: sessionDir.name,
|
|
1308
1343
|
sessionName: getSessionDisplayName(sessionDir.name, meta),
|
|
1309
|
-
project
|
|
1344
|
+
project
|
|
1310
1345
|
});
|
|
1311
1346
|
} catch (e) {
|
|
1312
1347
|
// Skip invalid files
|
|
@@ -1417,6 +1452,91 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
1417
1452
|
}
|
|
1418
1453
|
});
|
|
1419
1454
|
|
|
1455
|
+
// API: Markdown preview — read file and broadcast to clients
|
|
1456
|
+
async function readMarkdownFile(absPath) {
|
|
1457
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
1458
|
+
if (ext !== '.md' && ext !== '.markdown') {
|
|
1459
|
+
const err = new Error('Only .md/.markdown files are allowed');
|
|
1460
|
+
err.status = 400;
|
|
1461
|
+
throw err;
|
|
1462
|
+
}
|
|
1463
|
+
try {
|
|
1464
|
+
return await fs.readFile(absPath, 'utf8');
|
|
1465
|
+
} catch (e) {
|
|
1466
|
+
if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
|
|
1467
|
+
if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
|
|
1468
|
+
throw e;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function resolvePreviewPath(filePath) {
|
|
1473
|
+
if (!filePath || typeof filePath !== 'string') return null;
|
|
1474
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
app.post('/api/preview', async (req, res) => {
|
|
1478
|
+
try {
|
|
1479
|
+
const { path: filePath, sessionId } = req.body || {};
|
|
1480
|
+
const abs = resolvePreviewPath(filePath);
|
|
1481
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
1482
|
+
const content = await readMarkdownFile(abs);
|
|
1483
|
+
broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
|
|
1484
|
+
res.json({ success: true });
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
console.error('Error in /api/preview:', error);
|
|
1487
|
+
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
app.get('/api/session/resolve', (req, res) => {
|
|
1492
|
+
try {
|
|
1493
|
+
const idArg = (req.query.id || '').toString();
|
|
1494
|
+
if (!idArg) return res.status(400).json({ error: 'id is required' });
|
|
1495
|
+
const metadata = loadSessionMetadata();
|
|
1496
|
+
const ids = Object.keys(metadata);
|
|
1497
|
+
if (Object.hasOwn(metadata, idArg)) {
|
|
1498
|
+
const m = metadata[idArg];
|
|
1499
|
+
return res.json({ id: idArg, customTitle: m?.customTitle || null });
|
|
1500
|
+
}
|
|
1501
|
+
const matches = ids.filter(id => id.startsWith(idArg));
|
|
1502
|
+
if (matches.length === 0) return res.status(404).json({ matches: [] });
|
|
1503
|
+
if (matches.length > 1) {
|
|
1504
|
+
return res.status(409).json({
|
|
1505
|
+
matches: matches.slice(0, 50).map(id => ({ id, customTitle: metadata[id]?.customTitle || null }))
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
const id = matches[0];
|
|
1509
|
+
res.json({ id, customTitle: metadata[id]?.customTitle || null });
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
console.error('Error in /api/session/resolve:', error);
|
|
1512
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
app.post('/api/session/open', async (req, res) => {
|
|
1517
|
+
try {
|
|
1518
|
+
const { id } = req.body || {};
|
|
1519
|
+
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
|
|
1520
|
+
broadcast({ type: 'session:open', id });
|
|
1521
|
+
res.json({ success: true, id });
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
console.error('Error in /api/session/open:', error);
|
|
1524
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
app.get('/api/preview', async (req, res) => {
|
|
1529
|
+
try {
|
|
1530
|
+
const abs = resolvePreviewPath(req.query.path);
|
|
1531
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
1532
|
+
const content = await readMarkdownFile(abs);
|
|
1533
|
+
res.json({ path: abs, content });
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
console.error('Error in GET /api/preview:', error);
|
|
1536
|
+
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1420
1540
|
// SSE endpoint for live updates
|
|
1421
1541
|
app.get('/api/events', (req, res) => {
|
|
1422
1542
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -1454,9 +1574,6 @@ app.use('/api', (req, res) => {
|
|
|
1454
1574
|
res.status(404).json({ error: 'Not found' });
|
|
1455
1575
|
});
|
|
1456
1576
|
|
|
1457
|
-
// File watchers and server startup (skip for --install/--uninstall)
|
|
1458
|
-
if (!isSetupCommand) {
|
|
1459
|
-
|
|
1460
1577
|
// Watch for file changes (chokidar handles non-existent paths)
|
|
1461
1578
|
const watcher = chokidar.watch(TASKS_DIR, {
|
|
1462
1579
|
persistent: true,
|
|
@@ -1705,4 +1822,3 @@ const server = app.listen(PORT, () => {
|
|
|
1705
1822
|
}
|
|
1706
1823
|
});
|
|
1707
1824
|
|
|
1708
|
-
} // end if (!isSetupCommand)
|