claude-code-kanban 2.0.1 → 2.1.0-rc.2

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/sw.js CHANGED
@@ -1,5 +1,5 @@
1
- const CACHE_NAME = 'cc-kanban-v1';
2
- const PRECACHE_URLS = ['/'];
1
+ const CACHE_NAME = 'cc-kanban-v2';
2
+ const PRECACHE_URLS = ['/', '/style.css', '/app.js'];
3
3
 
4
4
  let cachePromise = null;
5
5
  function getCache() {
@@ -9,7 +9,8 @@ function getCache() {
9
9
 
10
10
  function cacheResponse(request, response) {
11
11
  if (response.ok) {
12
- getCache().then(cache => cache.put(request, response.clone()));
12
+ const clone = response.clone();
13
+ getCache().then(cache => cache.put(request, clone));
13
14
  }
14
15
  return response;
15
16
  }
package/server.js CHANGED
@@ -51,10 +51,11 @@ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
51
51
  const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
52
52
  const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
53
53
  const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
54
+ const CONTEXT_STATUS_DIR = path.join(CLAUDE_DIR, 'context-status');
54
55
 
55
56
  const PERMISSION_TTL_MS = 1800000;
56
57
  const AGENT_TTL_MS = 3600000;
57
- const AGENT_STALE_MS = 300000;
58
+ const AGENT_STALE_MS = 900000;
58
59
 
59
60
  const WAITING_RESOLVE_GRACE_MS = 15000;
60
61
 
@@ -73,8 +74,18 @@ function checkWaitingForUser(agentDir, logMtime) {
73
74
  return null;
74
75
  }
75
76
 
77
+ function isGhostAgent(agent) {
78
+ if (agent.startedAt !== agent.updatedAt || agent.lastMessage) return false;
79
+ return (Date.now() - new Date(agent.startedAt).getTime()) >= AGENT_STALE_MS;
80
+ }
81
+
82
+ function getContextStatus(sessionId, meta) {
83
+ return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
84
+ }
85
+
76
86
  function isAgentFresh(agent) {
77
87
  if (!agent.updatedAt) return true;
88
+ if (isGhostAgent(agent)) return false;
78
89
  return (Date.now() - new Date(agent.updatedAt).getTime()) < AGENT_TTL_MS;
79
90
  }
80
91
 
@@ -98,7 +109,6 @@ function checkAgentStatus(agentDir, stale, logMtime) {
98
109
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
99
110
  if (isAgentFresh(agent)) {
100
111
  if (agent.status === 'active') { result.hasActive = true; result.hasRunning = true; }
101
- else if (agent.status === 'idle') { result.hasActive = true; }
102
112
  }
103
113
  if (result.hasRunning && result.hasActive) break;
104
114
  } catch (e) { /* skip invalid */ }
@@ -171,6 +181,7 @@ const MAX_CACHE_ENTRIES = 200;
171
181
  const progressMapCache = new Map();
172
182
  const compactSummaryCache = new Map();
173
183
  const taskCountsCache = new Map();
184
+ const contextStatusCache = new Map();
174
185
 
175
186
  function evictStaleCache(cache) {
176
187
  if (cache.size <= MAX_CACHE_ENTRIES) return;
@@ -200,7 +211,8 @@ function getTaskCounts(sessionPath) {
200
211
  } catch (e) { /* skip invalid */ }
201
212
  }
202
213
 
203
- const result = { taskCount: taskFiles.length, completed, inProgress, pending, newestTaskMtime };
214
+ const taskCount = completed + inProgress + pending;
215
+ const result = { taskCount, completed, inProgress, pending, newestTaskMtime };
204
216
  taskCountsCache.set(sessionPath, result);
205
217
  return result;
206
218
  }
@@ -387,6 +399,9 @@ app.get('/api/sessions', async (req, res) => {
387
399
  const limitParam = req.query.limit || '20';
388
400
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
389
401
 
402
+ const pinnedParam = req.query.pinned;
403
+ const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
404
+
390
405
  const metadata = loadSessionMetadata();
391
406
  const sessionsMap = new Map();
392
407
 
@@ -416,12 +431,12 @@ app.get('/api/sessions', async (req, res) => {
416
431
  }
417
432
 
418
433
  const isTeam = isTeamSession(entry.name);
419
- const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
434
+ const teamConfig = isTeam ? loadTeamConfig(entry.name) : null;
435
+ const memberCount = teamConfig?.members?.length || 0;
420
436
  const planInfo = getPlanInfo(meta.slug);
421
437
 
422
438
  const resolvedAgentDir = (() => {
423
- const tc = loadTeamConfig(entry.name);
424
- const rid = (tc && tc.leadSessionId) ? tc.leadSessionId : entry.name;
439
+ const rid = teamConfig?.leadSessionId || entry.name;
425
440
  return path.join(AGENT_ACTIVITY_DIR, rid);
426
441
  })();
427
442
  const agentStatus = checkAgentStatus(resolvedAgentDir, stale, logMtime);
@@ -450,6 +465,7 @@ app.get('/api/sessions', async (req, res) => {
450
465
  jsonlPath: meta.jsonlPath || null,
451
466
  tasksDir: sessionPath,
452
467
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
468
+ contextStatus: getContextStatus(entry.name, meta),
453
469
  ...planInfo
454
470
  });
455
471
  }
@@ -495,6 +511,7 @@ app.get('/api/sessions', async (req, res) => {
495
511
  jsonlPath: meta.jsonlPath || null,
496
512
  tasksDir: null,
497
513
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
514
+ contextStatus: getContextStatus(sessionId, meta),
498
515
  ...planInfo
499
516
  });
500
517
  }
@@ -572,9 +589,16 @@ app.get('/api/sessions', async (req, res) => {
572
589
  }
573
590
  }
574
591
 
592
+ // Backfill contextStatus for already-built sessions that are pinned
593
+ for (const pid of pinnedIds) {
594
+ const s = sessionsMap.get(pid);
595
+ if (s && !s.contextStatus) {
596
+ const meta = metadata[pid];
597
+ s.contextStatus = getContextStatus(pid, meta);
598
+ }
599
+ }
600
+
575
601
  // Ensure pinned sessions are in the map even if they weren't discovered
576
- const pinnedParam = req.query.pinned;
577
- const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
578
602
  for (const pid of pinnedIds) {
579
603
  if (sessionsMap.has(pid)) continue;
580
604
  const meta = metadata[pid];
@@ -604,6 +628,7 @@ app.get('/api/sessions', async (req, res) => {
604
628
  jsonlPath: meta.jsonlPath || null,
605
629
  tasksDir: null,
606
630
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
631
+ contextStatus: getContextStatus(pid, meta),
607
632
  ...getPlanInfo(meta.slug)
608
633
  });
609
634
  }
@@ -774,6 +799,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
774
799
  for (const file of files) {
775
800
  try {
776
801
  const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
802
+ if (isGhostAgent(agent)) continue;
777
803
  const agentStale = !sessionStale && agent.updatedAt && (Date.now() - new Date(agent.updatedAt).getTime()) > AGENT_STALE_MS;
778
804
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
779
805
  if (agent.status === 'active' || agent.status === 'idle') {
@@ -811,7 +837,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
811
837
 
812
838
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
813
839
  const sessionId = resolveSessionId(req.params.sessionId);
814
- const agentId = path.basename(req.params.agentId).replace(/[^a-zA-Z0-9_-]/g, '');
840
+ const agentId = sanitizeAgentId(req.params.agentId);
815
841
  const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.json');
816
842
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
817
843
  try {
@@ -828,6 +854,83 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
828
854
  }
829
855
  });
830
856
 
857
+ function sanitizeAgentId(raw) {
858
+ return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
859
+ }
860
+
861
+ function subagentJsonlPath(meta, agentId) {
862
+ return path.join(
863
+ path.dirname(meta.jsonlPath),
864
+ path.basename(meta.jsonlPath, '.jsonl'),
865
+ 'subagents',
866
+ 'agent-' + agentId + '.jsonl'
867
+ );
868
+ }
869
+
870
+ app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
871
+ const sessionId = resolveSessionId(req.params.sessionId);
872
+ const agentId = sanitizeAgentId(req.params.agentId);
873
+ const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100);
874
+ const metadata = loadSessionMetadata();
875
+ const meta = metadata[sessionId];
876
+ if (!meta?.jsonlPath) return res.json({ messages: [], agentId });
877
+ const subagentJsonl = subagentJsonlPath(meta, agentId);
878
+ if (!existsSync(subagentJsonl)) return res.json({ messages: [], agentId });
879
+ const messages = readRecentMessages(subagentJsonl, limit);
880
+ res.json({ messages, agentId });
881
+ });
882
+
883
+ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) => {
884
+ const sessionId = resolveSessionId(req.params.sessionId);
885
+ const agentId = sanitizeAgentId(req.params.agentId);
886
+ const metadata = loadSessionMetadata();
887
+ const meta = metadata[sessionId];
888
+ if (!meta?.jsonlPath) {
889
+ res.status(404).json({ error: 'Session not found' });
890
+ return;
891
+ }
892
+ const subagentJsonl = subagentJsonlPath(meta, agentId);
893
+
894
+ res.writeHead(200, {
895
+ 'Content-Type': 'text/event-stream',
896
+ 'Cache-Control': 'no-cache',
897
+ 'Connection': 'keep-alive'
898
+ });
899
+ res.write('\n');
900
+
901
+ let lastSize = existsSync(subagentJsonl) ? statSync(subagentJsonl).size : 0;
902
+
903
+ const watcher = chokidar.watch(subagentJsonl, {
904
+ persistent: true,
905
+ ignoreInitial: true,
906
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
907
+ });
908
+
909
+ let closed = false;
910
+ const cleanup = () => { if (!closed) { closed = true; watcher.close(); } };
911
+
912
+ function emitMessages() {
913
+ const messages = readRecentMessages(subagentJsonl, 50);
914
+ lastSize = statSync(subagentJsonl).size;
915
+ res.write(`event: agent-log-update\ndata: ${JSON.stringify({ messages, agentId })}\n\n`);
916
+ }
917
+
918
+ watcher.on('change', () => {
919
+ try {
920
+ if (statSync(subagentJsonl).size <= lastSize) return;
921
+ emitMessages();
922
+ } catch (_) {}
923
+ });
924
+
925
+ watcher.on('add', () => {
926
+ try { emitMessages(); } catch (_) {}
927
+ });
928
+
929
+ req.on('close', cleanup);
930
+ res.on('close', cleanup);
931
+ res.on('error', cleanup);
932
+ });
933
+
831
934
  app.get('/api/sessions/:sessionId/messages', (req, res) => {
832
935
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
833
936
  const metadata = loadSessionMetadata();
@@ -1052,6 +1155,10 @@ function broadcast(data) {
1052
1155
  }
1053
1156
  }
1054
1157
 
1158
+ app.get('/api/context-status', (req, res) => {
1159
+ res.json(Object.fromEntries(contextStatusCache));
1160
+ });
1161
+
1055
1162
  app.use('/api', (req, res) => {
1056
1163
  res.status(404).json({ error: 'Not found' });
1057
1164
  });
@@ -1181,6 +1288,48 @@ agentActivityWatcher.on('all', (event, filePath) => {
1181
1288
  }
1182
1289
  });
1183
1290
 
1291
+ // Watch context-status directory for statusline updates
1292
+ const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
1293
+ persistent: true,
1294
+ ignoreInitial: false,
1295
+ depth: 0
1296
+ });
1297
+
1298
+ contextStatusWatcher.on('all', (event, filePath) => {
1299
+ if (!filePath.endsWith('.json')) return;
1300
+ const sessionId = path.basename(filePath, '.json');
1301
+ if (event === 'add' || event === 'change') {
1302
+ try {
1303
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
1304
+ contextStatusCache.set(sessionId, data);
1305
+ evictStaleCache(contextStatusCache);
1306
+ } catch (e) { /* ignore malformed */ }
1307
+ broadcast({ type: 'context-update', sessionId });
1308
+ } else if (event === 'unlink') {
1309
+ contextStatusCache.delete(sessionId);
1310
+ broadcast({ type: 'context-update', sessionId });
1311
+ }
1312
+ });
1313
+
1314
+ const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
1315
+ async function cleanupContextStatus() {
1316
+ try {
1317
+ const entries = await fs.readdir(CONTEXT_STATUS_DIR);
1318
+ const now = Date.now();
1319
+ for (const f of entries) {
1320
+ if (!f.endsWith('.json')) continue;
1321
+ try {
1322
+ const fp = path.join(CONTEXT_STATUS_DIR, f);
1323
+ const st = statSync(fp);
1324
+ if (now - st.mtimeMs > CTX_CLEANUP_MAX_AGE_MS) {
1325
+ await fs.unlink(fp);
1326
+ contextStatusCache.delete(path.basename(f, '.json'));
1327
+ }
1328
+ } catch (e) { /* ignore */ }
1329
+ }
1330
+ } catch (e) { /* dir may not exist */ }
1331
+ }
1332
+
1184
1333
  // Cleanup agent-activity folders older than 2 days
1185
1334
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
1186
1335
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
@@ -1205,7 +1354,9 @@ async function cleanupAgentActivity() {
1205
1354
  }
1206
1355
 
1207
1356
  cleanupAgentActivity();
1357
+ cleanupContextStatus();
1208
1358
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
1359
+ setInterval(cleanupContextStatus, 30 * 60 * 1000);
1209
1360
 
1210
1361
  const server = app.listen(PORT, () => {
1211
1362
  const actualPort = server.address().port;