claude-code-kanban 3.2.4 → 3.4.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/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
  }
@@ -2489,6 +2498,28 @@ body::before {
2489
2498
  cursor: default;
2490
2499
  }
2491
2500
 
2501
+ .linked-docs-badge,
2502
+ .bookmarks-badge {
2503
+ display: inline-flex;
2504
+ align-items: center;
2505
+ gap: 2px;
2506
+ font-size: 10px;
2507
+ padding: 2px 6px;
2508
+ background: var(--bg-elevated);
2509
+ border: 1px solid var(--border);
2510
+ border-radius: 10px;
2511
+ color: var(--text-secondary);
2512
+ cursor: pointer;
2513
+ flex-shrink: 0;
2514
+ line-height: 1;
2515
+ }
2516
+
2517
+ .linked-docs-badge:hover,
2518
+ .bookmarks-badge:hover {
2519
+ border-color: var(--accent);
2520
+ color: var(--text-primary);
2521
+ }
2522
+
2492
2523
  /* #endregion */
2493
2524
 
2494
2525
  /* #region PERMISSION_PENDING */
@@ -2689,6 +2720,68 @@ body.light .msg-assistant .msg-text {
2689
2720
  max-height: 92vh;
2690
2721
  }
2691
2722
 
2723
+ .modal.preview-modal-dialog {
2724
+ width: 90vw;
2725
+ max-width: 1200px;
2726
+ max-height: 90vh;
2727
+ display: flex;
2728
+ flex-direction: column;
2729
+ }
2730
+
2731
+ .modal.preview-modal-dialog.fullscreen {
2732
+ width: 90vw;
2733
+ max-width: 90vw;
2734
+ height: 90vh;
2735
+ max-height: 90vh;
2736
+ }
2737
+
2738
+ .preview-fm {
2739
+ margin: 0 0 14px;
2740
+ border: 1px solid var(--border);
2741
+ border-radius: 6px;
2742
+ background: var(--bg-elevated, rgba(127, 127, 127, 0.06));
2743
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
2744
+ font-size: 11px;
2745
+ }
2746
+
2747
+ .preview-fm > summary {
2748
+ cursor: pointer;
2749
+ padding: 6px 10px;
2750
+ color: var(--text-muted);
2751
+ text-transform: uppercase;
2752
+ letter-spacing: 0.06em;
2753
+ font-size: 10px;
2754
+ user-select: none;
2755
+ }
2756
+
2757
+ .preview-fm[open] > summary {
2758
+ border-bottom: 1px solid var(--border);
2759
+ }
2760
+
2761
+ .preview-fm .fm-grid {
2762
+ padding: 8px 10px;
2763
+ display: grid;
2764
+ gap: 4px 12px;
2765
+ }
2766
+
2767
+ .preview-fm .fm-row {
2768
+ display: grid;
2769
+ grid-template-columns: minmax(80px, 140px) 1fr;
2770
+ gap: 12px;
2771
+ align-items: baseline;
2772
+ }
2773
+
2774
+ .preview-fm .fm-k {
2775
+ color: var(--accent, #7aa2f7);
2776
+ font-weight: 600;
2777
+ }
2778
+
2779
+ .preview-fm .fm-v {
2780
+ color: var(--text, inherit);
2781
+ word-break: break-word;
2782
+ white-space: pre-wrap;
2783
+ }
2784
+
2692
2785
  .modal-sm {
2693
2786
  max-width: 440px;
2694
2787
  }
package/server.js CHANGED
@@ -15,19 +15,22 @@ const {
15
15
  readMessagesPage: _readMessagesPageUncached,
16
16
  readSessionInfoFromJsonl,
17
17
  buildAgentProgressMap,
18
+ buildSessionDigest,
18
19
  readCompactSummaries,
19
20
  findTerminatedTeammates,
20
- extractPromptFromTranscript
21
+ extractPromptFromTranscript,
22
+ extractModelFromTranscript
21
23
  } = require('./lib/parsers');
22
24
 
23
- const isSetupCommand = process.argv.includes('--install') || process.argv.includes('--uninstall');
24
-
25
- if (isSetupCommand) {
26
- const { runInstall, runUninstall } = require('./install');
27
- (process.argv.includes('--install') ? runInstall() : runUninstall())
25
+ if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
26
+ const { runInstall, runUninstall } = require("./install");
27
+ (process.argv.includes("--install") ? runInstall() : runUninstall())
28
28
  .then(() => process.exit(0))
29
29
  .catch(e => { console.error(e.message); process.exit(1); });
30
+ return;
30
31
  }
32
+ if (require("./cli").runCli(process.argv)) return;
33
+
31
34
 
32
35
  const app = express();
33
36
  const PORT = process.env.PORT || 3456;
@@ -79,7 +82,10 @@ const WAITING_RESOLVE_GRACE_MS = 15000;
79
82
 
80
83
  function persistAgent(dir, agent) {
81
84
  const file = path.join(dir, agent.agentId + '.json');
82
- fs.writeFile(file, JSON.stringify(agent), 'utf8').catch(() => {});
85
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
86
+ fs.writeFile(tmp, JSON.stringify(agent), 'utf8')
87
+ .then(() => fs.rename(tmp, file))
88
+ .catch(() => { fs.unlink(tmp).catch(() => {}); });
83
89
  }
84
90
 
85
91
  function checkWaitingForUser(agentDir, logMtime) {
@@ -209,8 +215,6 @@ app.use(express.static(path.join(__dirname, 'public')));
209
215
  const messageCache = new Map();
210
216
  const MESSAGE_CACHE_TTL = 5000;
211
217
  const MAX_CACHE_ENTRIES = 200;
212
- const progressMapCache = new Map();
213
- const terminatedCache = new Map();
214
218
  const compactSummaryCache = new Map();
215
219
  const taskCountsCache = new Map();
216
220
  const contextStatusCache = new Map();
@@ -324,12 +328,17 @@ function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
324
328
  } catch (_) { return fallback; }
325
329
  }
326
330
 
331
+ const sessionDigestCache = new Map();
332
+ function getSessionDigest(jsonlPath) {
333
+ return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
334
+ }
335
+
327
336
  function getProgressMap(jsonlPath) {
328
- return cachedByMtime(progressMapCache, jsonlPath, jsonlPath, () => buildAgentProgressMap(jsonlPath), {});
337
+ return getSessionDigest(jsonlPath).progressMap;
329
338
  }
330
339
 
331
340
  function getTerminatedTeammates(jsonlPath) {
332
- return cachedByMtime(terminatedCache, jsonlPath, jsonlPath, () => findTerminatedTeammates(jsonlPath), new Set());
341
+ return getSessionDigest(jsonlPath).terminated;
333
342
  }
334
343
 
335
344
  function readRecentMessages(jsonlPath, limit = 10) {
@@ -791,6 +800,12 @@ app.get('/api/sessions', async (req, res) => {
791
800
  let sessions = Array.from(sessionsMap.values());
792
801
  sessions.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt));
793
802
 
803
+ // Apply project filter before limit so the limit is per-project
804
+ const projectFilter = req.query.project;
805
+ if (projectFilter) {
806
+ sessions = sessions.filter(s => s.project === projectFilter);
807
+ }
808
+
794
809
  // Apply limit if specified, but always include pinned sessions
795
810
  if (limit !== null && limit > 0) {
796
811
  const top = sessions.slice(0, limit);
@@ -958,10 +973,14 @@ app.post('/api/open-folder', (req, res) => {
958
973
  }
959
974
  });
960
975
 
961
- // API: Open content in editor as temp file
976
+ // API: Open file in editor — either an existing path ({ file }) or content as a temp file ({ content, title })
962
977
  app.post('/api/open-in-editor', (req, res) => {
963
978
  try {
964
- const { content, title } = req.body;
979
+ const { content, title, file } = req.body;
980
+ if (file) {
981
+ openInEditor(file);
982
+ return res.json({ success: true, path: file });
983
+ }
965
984
  if (!content) return res.status(400).json({ error: 'No content provided' });
966
985
 
967
986
  const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
@@ -1049,14 +1068,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1049
1068
  } catch (_) {}
1050
1069
  }
1051
1070
 
1052
- function persistPrompt(agent, prompt) {
1053
- agent.prompt = prompt;
1054
- persistAgent(agentDir, agent);
1055
- }
1071
+ const dirty = new Set();
1056
1072
 
1057
- const agentsNeedingPrompt = agents.filter(a => !a.prompt);
1058
- const agentsNeedingName = agents.filter(a => !a.agentName);
1059
- const agentsNeedingDesc = agents.filter(a => !a.description);
1073
+ const agentsNeedingPrompt = agents.filter(a => !a.prompt && !a.promptUnavailable);
1074
+ const agentsNeedingName = agents.filter(a => !a.agentName && !a.agentNameUnavailable);
1075
+ const agentsNeedingDesc = agents.filter(a => !a.description && !a.descriptionUnavailable);
1060
1076
  if ((agentsNeedingPrompt.length || agentsNeedingName.length || agentsNeedingDesc.length) && meta.jsonlPath) {
1061
1077
  let byAgentId = {};
1062
1078
  let nameByAgentId = {};
@@ -1072,37 +1088,34 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1072
1088
  for (const agent of agentsNeedingPrompt) {
1073
1089
  const prompt = byAgentId[agent.agentId]
1074
1090
  || (() => { try { return extractPromptFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) { return null; } })();
1075
- if (prompt) persistPrompt(agent, prompt);
1091
+ if (prompt) agent.prompt = prompt;
1092
+ else agent.promptUnavailable = true;
1093
+ dirty.add(agent);
1076
1094
  }
1077
1095
  for (const agent of agentsNeedingName) {
1078
1096
  if (nameByAgentId[agent.agentId]) agent.agentName = nameByAgentId[agent.agentId];
1097
+ else agent.agentNameUnavailable = true;
1098
+ dirty.add(agent);
1079
1099
  }
1080
1100
  for (const agent of agentsNeedingDesc) {
1081
1101
  if (descByAgentId[agent.agentId]) agent.description = descByAgentId[agent.agentId];
1102
+ else agent.descriptionUnavailable = true;
1103
+ dirty.add(agent);
1082
1104
  }
1083
1105
  }
1084
1106
 
1085
- const agentsNeedingModel = agents.filter(a => !a.model);
1107
+ const agentsNeedingModel = agents.filter(a => !a.model && !a.modelUnavailable);
1086
1108
  if (agentsNeedingModel.length && meta.jsonlPath) {
1087
1109
  for (const agent of agentsNeedingModel) {
1088
- try {
1089
- const jsonl = subagentJsonlPath(meta, agent.agentId);
1090
- const content = readFileSync(jsonl, 'utf8');
1091
- for (const line of content.split('\n')) {
1092
- if (!line.trim()) continue;
1093
- try {
1094
- const obj = JSON.parse(line);
1095
- const model = obj.model || (obj.message && obj.message.model);
1096
- if (model) {
1097
- agent.model = model;
1098
- persistAgent(agentDir, agent);
1099
- break;
1100
- }
1101
- } catch (_) {}
1102
- }
1103
- } catch (_) {}
1110
+ let model = null;
1111
+ try { model = extractModelFromTranscript(subagentJsonlPath(meta, agent.agentId)); } catch (_) {}
1112
+ if (model) agent.model = model;
1113
+ else agent.modelUnavailable = true;
1114
+ dirty.add(agent);
1104
1115
  }
1105
1116
  }
1117
+
1118
+ for (const agent of dirty) persistAgent(agentDir, agent);
1106
1119
  const teamColors = {};
1107
1120
  if (teamConfig?.members) {
1108
1121
  for (const m of teamConfig.members) {
@@ -1303,6 +1316,7 @@ app.get('/api/tasks/all', async (req, res) => {
1303
1316
  }
1304
1317
 
1305
1318
  const metadata = loadSessionMetadata();
1319
+ const { listToSessions } = loadAllTaskMaps();
1306
1320
  const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
1307
1321
  .filter(d => d.isDirectory());
1308
1322
 
@@ -1313,6 +1327,19 @@ app.get('/api/tasks/all', async (req, res) => {
1313
1327
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
1314
1328
  const meta = metadata[sessionDir.name] || {};
1315
1329
 
1330
+ // For custom task list directories (non-UUID dirs), resolve project from the
1331
+ // mapped sessions since those dirs don't have their own metadata entry.
1332
+ let project = meta.project || null;
1333
+ if (!project) {
1334
+ const mappedSessions = listToSessions[sessionDir.name];
1335
+ if (mappedSessions) {
1336
+ for (const [sid, info] of Object.entries(mappedSessions)) {
1337
+ project = metadata[sid]?.project || info.project || null;
1338
+ if (project) break;
1339
+ }
1340
+ }
1341
+ }
1342
+
1316
1343
  for (const file of taskFiles) {
1317
1344
  try {
1318
1345
  const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
@@ -1320,7 +1347,7 @@ app.get('/api/tasks/all', async (req, res) => {
1320
1347
  ...task,
1321
1348
  sessionId: sessionDir.name,
1322
1349
  sessionName: getSessionDisplayName(sessionDir.name, meta),
1323
- project: meta.project || null
1350
+ project
1324
1351
  });
1325
1352
  } catch (e) {
1326
1353
  // Skip invalid files
@@ -1431,6 +1458,91 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
1431
1458
  }
1432
1459
  });
1433
1460
 
1461
+ // API: Markdown preview — read file and broadcast to clients
1462
+ async function readMarkdownFile(absPath) {
1463
+ const ext = path.extname(absPath).toLowerCase();
1464
+ if (ext !== '.md' && ext !== '.markdown') {
1465
+ const err = new Error('Only .md/.markdown files are allowed');
1466
+ err.status = 400;
1467
+ throw err;
1468
+ }
1469
+ try {
1470
+ return await fs.readFile(absPath, 'utf8');
1471
+ } catch (e) {
1472
+ if (e.code === 'ENOENT') { const err = new Error('File not found'); err.status = 404; throw err; }
1473
+ if (e.code === 'EISDIR') { const err = new Error('Not a file'); err.status = 400; throw err; }
1474
+ throw e;
1475
+ }
1476
+ }
1477
+
1478
+ function resolvePreviewPath(filePath) {
1479
+ if (!filePath || typeof filePath !== 'string') return null;
1480
+ return path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
1481
+ }
1482
+
1483
+ app.post('/api/preview', async (req, res) => {
1484
+ try {
1485
+ const { path: filePath, sessionId } = req.body || {};
1486
+ const abs = resolvePreviewPath(filePath);
1487
+ if (!abs) return res.status(400).json({ error: 'path is required' });
1488
+ const content = await readMarkdownFile(abs);
1489
+ broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
1490
+ res.json({ success: true });
1491
+ } catch (error) {
1492
+ console.error('Error in /api/preview:', error);
1493
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
1494
+ }
1495
+ });
1496
+
1497
+ app.get('/api/session/resolve', (req, res) => {
1498
+ try {
1499
+ const idArg = (req.query.id || '').toString();
1500
+ if (!idArg) return res.status(400).json({ error: 'id is required' });
1501
+ const metadata = loadSessionMetadata();
1502
+ const ids = Object.keys(metadata);
1503
+ if (Object.hasOwn(metadata, idArg)) {
1504
+ const m = metadata[idArg];
1505
+ return res.json({ id: idArg, customTitle: m?.customTitle || null });
1506
+ }
1507
+ const matches = ids.filter(id => id.startsWith(idArg));
1508
+ if (matches.length === 0) return res.status(404).json({ matches: [] });
1509
+ if (matches.length > 1) {
1510
+ return res.status(409).json({
1511
+ matches: matches.slice(0, 50).map(id => ({ id, customTitle: metadata[id]?.customTitle || null }))
1512
+ });
1513
+ }
1514
+ const id = matches[0];
1515
+ res.json({ id, customTitle: metadata[id]?.customTitle || null });
1516
+ } catch (error) {
1517
+ console.error('Error in /api/session/resolve:', error);
1518
+ res.status(500).json({ error: error.message || 'Failed' });
1519
+ }
1520
+ });
1521
+
1522
+ app.post('/api/session/open', async (req, res) => {
1523
+ try {
1524
+ const { id } = req.body || {};
1525
+ if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
1526
+ broadcast({ type: 'session:open', id });
1527
+ res.json({ success: true, id });
1528
+ } catch (error) {
1529
+ console.error('Error in /api/session/open:', error);
1530
+ res.status(500).json({ error: error.message || 'Failed' });
1531
+ }
1532
+ });
1533
+
1534
+ app.get('/api/preview', async (req, res) => {
1535
+ try {
1536
+ const abs = resolvePreviewPath(req.query.path);
1537
+ if (!abs) return res.status(400).json({ error: 'path is required' });
1538
+ const content = await readMarkdownFile(abs);
1539
+ res.json({ path: abs, content });
1540
+ } catch (error) {
1541
+ console.error('Error in GET /api/preview:', error);
1542
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
1543
+ }
1544
+ });
1545
+
1434
1546
  // SSE endpoint for live updates
1435
1547
  app.get('/api/events', (req, res) => {
1436
1548
  res.setHeader('Content-Type', 'text/event-stream');
@@ -1468,9 +1580,6 @@ app.use('/api', (req, res) => {
1468
1580
  res.status(404).json({ error: 'Not found' });
1469
1581
  });
1470
1582
 
1471
- // File watchers and server startup (skip for --install/--uninstall)
1472
- if (!isSetupCommand) {
1473
-
1474
1583
  // Watch for file changes (chokidar handles non-existent paths)
1475
1584
  const watcher = chokidar.watch(TASKS_DIR, {
1476
1585
  persistent: true,
@@ -1719,4 +1828,3 @@ const server = app.listen(PORT, () => {
1719
1828
  }
1720
1829
  });
1721
1830
 
1722
- } // end if (!isSetupCommand)