claude-code-kanban 4.19.0 → 4.21.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/server.js CHANGED
@@ -33,6 +33,7 @@ const {
33
33
  buildLoopInfoFromState
34
34
  } = require('./lib/parsers');
35
35
  const { inlineHtmlAssets } = require('./lib/inline-assets');
36
+ const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
36
37
 
37
38
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
38
39
  const { runInstall, runUninstall } = require("./install");
@@ -94,6 +95,7 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
94
95
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
95
96
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
96
97
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
98
+ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
97
99
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
98
100
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
99
101
 
@@ -106,17 +108,27 @@ function readPins() {
106
108
  return {};
107
109
  }
108
110
 
109
- function writePins(pins) {
111
+ function writeJsonAtomic(file, obj) {
110
112
  try {
111
113
  mkdirSync(CCK_DIR, { recursive: true });
112
- const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
113
- writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
114
- renameSync(tmp, PINS_FILE);
114
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
115
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
116
+ renameSync(tmp, file);
115
117
  } catch (e) {
116
- console.error('Failed to write pins.json:', e.message);
118
+ console.error(`Failed to write ${path.basename(file)}:`, e.message);
117
119
  }
118
120
  }
119
121
 
122
+ function writePins(pins) {
123
+ writeJsonAtomic(PINS_FILE, pins);
124
+ }
125
+
126
+ // Port discovery for out-of-process helpers (the postman monitor). The pid rides along
127
+ // so a reader can tell a live server from a file left behind by a crashed one.
128
+ function writeServerInfo(port) {
129
+ writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
130
+ }
131
+
120
132
  // #region TIMINGS
121
133
  const PERMISSION_TTL_MS = 30 * 60 * 1000;
122
134
  const AGENT_TTL_MS = 60 * 60 * 1000;
@@ -157,6 +169,25 @@ function persistAgent(dir, agent) {
157
169
  fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
158
170
  }
159
171
 
172
+ // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
173
+ // this only reads the config, cached so the polled session list doesn't re-read
174
+ // it per hit (marker stays visible for the badge until TTL).
175
+ const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
176
+ let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
177
+ function approvalsWaitMs() {
178
+ if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
179
+ let cfg = null;
180
+ try {
181
+ cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
182
+ } catch (e) { /* missing config — gate default */ }
183
+ approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
184
+ return approvalsWaitCache.ms;
185
+ }
186
+
187
+ function isWaitingLapsed(data) {
188
+ return isLapsed(data.timestamp, approvalsWaitMs());
189
+ }
190
+
160
191
  function checkWaitingForUser(agentDir, logMtime) {
161
192
  try {
162
193
  const data = JSON.parse(readFileSync(path.join(agentDir, '_waiting.json'), 'utf8'));
@@ -166,6 +197,7 @@ function checkWaitingForUser(agentDir, logMtime) {
166
197
  if (age >= PERMISSION_TTL_MS) return null;
167
198
  // After grace period, check if session resumed activity (user already responded)
168
199
  if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
200
+ if (isWaitingLapsed(data)) return { ...data, lapsed: true };
169
201
  return data;
170
202
  }
171
203
  } catch (e) { /* skip — missing or invalid */ }
@@ -562,6 +594,13 @@ function getCustomTaskDir(sessionId) {
562
594
  return null;
563
595
  }
564
596
 
597
+ // Where a session's task files live. The custom-list and team lookups can both miss, and
598
+ // the fallback is the plain per-session dir -- every route that touches a task file needs
599
+ // that same resolution, so it lives in one place.
600
+ function taskDirFor(sessionId) {
601
+ return getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
602
+ }
603
+
565
604
  function getTaskCounts(sessionPath) {
566
605
  const cached = taskCountsCache.get(sessionPath);
567
606
  if (cached) return cached;
@@ -1409,8 +1448,7 @@ app.get('/api/projects', (req, res) => {
1409
1448
  // API: Get tasks for a session
1410
1449
  app.get('/api/sessions/:sessionId', async (req, res) => {
1411
1450
  try {
1412
- const customDir = getCustomTaskDir(req.params.sessionId);
1413
- const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
1451
+ const sessionPath = taskDirFor(req.params.sessionId);
1414
1452
 
1415
1453
  if (!existsSync(sessionPath)) {
1416
1454
  return res.status(404).json({ error: 'Session not found' });
@@ -2032,6 +2070,31 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
2032
2070
  }
2033
2071
  });
2034
2072
 
2073
+ // UI-driven approvals: answers the ask approval-gate.sh is blocking on by
2074
+ // writing _decision-<id>.json next to the marker. The hook consumes and deletes
2075
+ // both files; a write for a hook that already gave up is accepted anyway (D13)
2076
+ // and left for cleanupAgentActivity's sweep.
2077
+ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2078
+ const sessionId = resolveSessionId(req.params.sessionId);
2079
+ const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2080
+ let marker = null;
2081
+ try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
2082
+ catch (e) { /* missing or invalid → buildDecision reports 410 */ }
2083
+ // The gate stopped polling after waitSeconds — an orphaned decision file would
2084
+ // sit unconsumed while the card pretends the click worked.
2085
+ if (marker && isWaitingLapsed(marker)) {
2086
+ return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
2087
+ }
2088
+ const result = buildDecision(marker, req.body || {});
2089
+ if (result.error) return res.status(result.status).json({ error: result.error });
2090
+ try {
2091
+ writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
2092
+ res.json({ ok: true });
2093
+ } catch (e) {
2094
+ res.status(500).json({ error: 'Failed to write decision' });
2095
+ }
2096
+ });
2097
+
2035
2098
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
2036
2099
  const sessionId = resolveSessionId(req.params.sessionId);
2037
2100
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -2636,13 +2699,54 @@ app.get('/api/tasks/all', async (req, res) => {
2636
2699
  }
2637
2700
  });
2638
2701
 
2702
+ const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
2703
+ app.get('/api/sessions/:sessionId/events', handleSessionEvents);
2704
+
2705
+ // API: Create a task
2706
+ app.post('/api/tasks/:sessionId', async (req, res) => {
2707
+ try {
2708
+ const { sessionId } = req.params;
2709
+ const subject = (req.body.subject || '').trim();
2710
+ if (!subject) return res.status(400).json({ error: 'Subject is required' });
2711
+
2712
+ const sessionDir = taskDirFor(sessionId);
2713
+ if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
2714
+
2715
+ // Ids are the agent's own numbering scheme, so a hand-made task has to keep counting
2716
+ // from the highest one on disk -- reusing a number would overwrite that task's file.
2717
+ const ids = readdirSync(sessionDir)
2718
+ .filter((f) => f.endsWith('.json'))
2719
+ .map((f) => parseInt(path.basename(f, '.json'), 10))
2720
+ .filter((n) => Number.isInteger(n));
2721
+ const id = String(Math.max(0, ...ids) + 1);
2722
+
2723
+ const task = {
2724
+ id,
2725
+ subject,
2726
+ description: (req.body.description || '').trim(),
2727
+ activeForm: subject,
2728
+ status: 'pending',
2729
+ blocks: [],
2730
+ blockedBy: [],
2731
+ };
2732
+
2733
+ // No doorbell here, unlike a move: the user typed this task, so telling their session
2734
+ // about it would only repeat what they just said. Dragging it to In Progress rings.
2735
+ await fs.writeFile(path.join(sessionDir, `${id}.json`), JSON.stringify(task, null, 2));
2736
+ res.json({ success: true, task });
2737
+ } catch (error) {
2738
+ console.error('Error creating task:', error);
2739
+ res.status(500).json({ error: 'Failed to create task' });
2740
+ }
2741
+ });
2742
+
2639
2743
  // API: Update task fields (subject, description)
2640
2744
  app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2641
2745
  try {
2642
2746
  const { sessionId, taskId } = req.params;
2643
2747
  const { subject, description } = req.body;
2644
2748
 
2645
- const sessionDir = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2749
+ const sessionDir = taskDirFor(sessionId);
2646
2750
  const taskPath = path.join(sessionDir, `${taskId}.json`);
2647
2751
 
2648
2752
  if (!existsSync(taskPath)) {
@@ -2650,6 +2754,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2650
2754
  }
2651
2755
 
2652
2756
  const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
2757
+ const prevStatus = task.status;
2653
2758
 
2654
2759
  if (subject !== undefined) task.subject = subject;
2655
2760
  if (description !== undefined) task.description = description;
@@ -2657,6 +2762,17 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2657
2762
 
2658
2763
  await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
2659
2764
 
2765
+ // Ring the session only for a move. The direction has to ride in the line because
2766
+ // the write above destroyed the old status -- nothing downstream can recover it, and
2767
+ // which way a task moved is what decides whether to start work or stop it.
2768
+ if (task.status !== prevStatus) {
2769
+ // The route param is a task *directory*, which for a shared list or team board is
2770
+ // not a session id -- and the postman polls with its own session id, so an unresolved
2771
+ // name would queue the line where nobody drains it.
2772
+ const line = formatTaskMoved(taskId, prevStatus, task);
2773
+ for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
2774
+ }
2775
+
2660
2776
  res.json({ success: true, task });
2661
2777
  } catch (error) {
2662
2778
  console.error('Error updating task:', error);
@@ -2668,7 +2784,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
2668
2784
  app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2669
2785
  try {
2670
2786
  const { sessionId, taskId } = req.params;
2671
- const sessionPath = getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
2787
+ const sessionPath = taskDirFor(sessionId);
2672
2788
  const taskPath = path.join(sessionPath, `${taskId}.json`);
2673
2789
 
2674
2790
  if (!existsSync(taskPath)) {
@@ -2964,27 +3080,27 @@ watcher.on('all', (event, filePath) => {
2964
3080
 
2965
3081
  taskCountsCache.delete(path.join(TASKS_DIR, dirName));
2966
3082
 
2967
- if (isUUID(dirName)) {
2968
- broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
2969
- } else {
2970
- broadcastToMappedSessions(dirName, event, filePath);
2971
- }
3083
+ broadcastToMappedSessions(dirName, event, filePath);
2972
3084
  }
2973
3085
  });
2974
3086
 
2975
- function broadcastToMappedSessions(taskListName, event, filePath) {
3087
+ // Which sessions own a task directory. Usually the dir name IS the session id, but a
3088
+ // shared list or a team dir is one directory several sessions map onto -- so anything
3089
+ // addressing a session from a task path has to fan out the same way. One resolver for
3090
+ // both readers of that mapping: the SSE broadcast and the doorbell.
3091
+ function resolveSessionsForTaskDir(name) {
3092
+ if (isUUID(name)) return [name];
2976
3093
  const { listToSessions } = loadAllTaskMaps();
2977
- const map = listToSessions[taskListName];
2978
- if (map) {
2979
- for (const sid of Object.keys(map)) {
2980
- broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
2981
- }
2982
- return;
2983
- }
2984
- // Fallback: check if taskListName is a team name
2985
- const cfg = loadTeamConfig(taskListName);
2986
- if (cfg?.leadSessionId) {
2987
- broadcast({ type: 'update', event, sessionId: cfg.leadSessionId, file: path.basename(filePath) });
3094
+ const map = listToSessions[name];
3095
+ if (map) return Object.keys(map);
3096
+ // Fallback: check if name is a team name
3097
+ const cfg = loadTeamConfig(name);
3098
+ return cfg?.leadSessionId ? [cfg.leadSessionId] : [];
3099
+ }
3100
+
3101
+ function broadcastToMappedSessions(taskListName, event, filePath) {
3102
+ for (const sid of resolveSessionsForTaskDir(taskListName)) {
3103
+ broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
2988
3104
  }
2989
3105
  }
2990
3106
 
@@ -3176,6 +3292,18 @@ async function cleanupAgentActivity() {
3176
3292
  const age = now - stat.mtimeMs;
3177
3293
  if ((contents.length === 0 && age > AGENT_STALE_MS) || age > CLEANUP_MAX_AGE_MS) {
3178
3294
  await fs.rm(dirPath, { recursive: true, force: true });
3295
+ continue;
3296
+ }
3297
+ // Orphaned decision files: a terminal deny fires no hook event (D13), so a
3298
+ // board answer for an already-gone gate is never consumed. The gate waits at
3299
+ // most PERMISSION_TTL_MS, so anything older is unclaimable.
3300
+ for (const f of contents) {
3301
+ if (!isDecisionFile(f)) continue;
3302
+ const fp = path.join(dirPath, f);
3303
+ try {
3304
+ const fst = await fs.stat(fp);
3305
+ if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
3306
+ } catch (e) { /* ignore per-file errors */ }
3179
3307
  }
3180
3308
  } catch (e) { /* ignore per-folder errors */ }
3181
3309
  }
@@ -3213,6 +3341,9 @@ async function prewarmCaches() {
3213
3341
 
3214
3342
  const onReady = (actualPort) => {
3215
3343
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
3344
+ // The port is configurable and falls back to a random one when taken, so the postman
3345
+ // monitor cannot assume it -- publish the live one where it can read it.
3346
+ writeServerInfo(actualPort);
3216
3347
  const warning = net.exposureWarning();
3217
3348
  if (warning) console.log(warning);
3218
3349