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/README.md +10 -1
- package/lib/approvals.js +82 -0
- package/lib/session-events.js +99 -0
- package/package.json +2 -2
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +5 -2
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +2 -24
- package/plugin/plugins/claude-code-kanban/monitors.json +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -3
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +141 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +67 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +20 -29
- package/plugin/plugins/claude-code-kanban/skills/kanban-follow/SKILL.md +39 -0
- package/public/app.js +582 -90
- package/public/index.html +2 -1
- package/public/style.css +359 -5
- package/server.js +157 -26
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
|
|
111
|
+
function writeJsonAtomic(file, obj) {
|
|
110
112
|
try {
|
|
111
113
|
mkdirSync(CCK_DIR, { recursive: true });
|
|
112
|
-
const tmp = `${
|
|
113
|
-
writeFileSync(tmp, JSON.stringify(
|
|
114
|
-
renameSync(tmp,
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
2978
|
-
if (map)
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
const
|
|
2986
|
-
|
|
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
|
|