claude-code-kanban 4.25.0 → 4.27.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 +1 -1
- package/install.js +16 -1
- package/lib/approvals.js +34 -3
- package/lib/inline-assets.js +1 -1
- package/lib/parsers.js +177 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +53 -18
- package/public/app.js +667 -79
- package/public/index.html +19 -0
- package/public/style.css +355 -18
- package/server.js +527 -89
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
|
|
|
51
51
|
- **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
|
|
52
52
|
- **Tool stats & impact** — Per-session tool usage breakdown and file impact
|
|
53
53
|
- **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
|
|
54
|
-
- **UI approvals
|
|
54
|
+
- **UI approvals** — Allow/deny permission asks and answer questions from the board; on by default, opt out per config dir — [docs](docs/ui-approvals.md)
|
|
55
55
|
- **Agent teams** — Color-coded team members, owner filtering, member count badges
|
|
56
56
|
- **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
|
|
57
57
|
- **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
|
package/install.js
CHANGED
|
@@ -130,12 +130,27 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
130
130
|
if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
|
|
131
131
|
|
|
132
132
|
const inst = runCLI('claude plugin install claude-code-kanban@claude-code-kanban', ['already installed', 'already exists']);
|
|
133
|
+
const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
|
|
133
134
|
if (inst.ok) {
|
|
134
|
-
console.log(` ${green('✓')} ${
|
|
135
|
+
console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
|
|
135
136
|
} else {
|
|
136
137
|
console.log(` ${red('✗')} Plugin install failed: ${inst.error}`);
|
|
137
138
|
failed = true;
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
// `install` is a no-op once the plugin is present — it exits 0 and only says so
|
|
142
|
+
// on stdout — so a re-copied plugin keeps running from the old cached version
|
|
143
|
+
// until `update` pulls the new one in. That is how a config dir ends up serving
|
|
144
|
+
// a plugin older than the board it talks to.
|
|
145
|
+
if (alreadyInstalled) {
|
|
146
|
+
const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
|
|
147
|
+
if (upgrade.ok) {
|
|
148
|
+
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
149
|
+
console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
139
154
|
} else {
|
|
140
155
|
console.log(` ${dim('Skipped')}`);
|
|
141
156
|
}
|
package/lib/approvals.js
CHANGED
|
@@ -63,20 +63,51 @@ function isDecisionFile(name) {
|
|
|
63
63
|
// (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
|
|
64
64
|
// can show the ask is strictly worse — D11); keep them in sync with
|
|
65
65
|
// approval-gate.sh.
|
|
66
|
-
const WAIT_SECONDS_DEFAULT =
|
|
66
|
+
const WAIT_SECONDS_DEFAULT = 1800;
|
|
67
67
|
const WAIT_SECONDS_MAX = 1800;
|
|
68
68
|
// Small grace over the gate's own deadline so a race never 410s a live hook
|
|
69
69
|
const LAPSE_GRACE_MS = 5000;
|
|
70
|
-
|
|
71
70
|
function waitSecondsFrom(cfg) {
|
|
72
71
|
const raw = cfg && cfg.waitSeconds;
|
|
73
72
|
if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
|
|
74
73
|
return WAIT_SECONDS_DEFAULT;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
|
|
77
|
+
// by default: only an explicit `enabled: false` turns it off, and anything
|
|
78
|
+
// unparseable falls back to defaults — the same parse approval-gate.sh does.
|
|
79
|
+
function approvalsFrom(cfg) {
|
|
80
|
+
const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
|
|
81
|
+
return {
|
|
82
|
+
enabled: a.enabled !== false,
|
|
83
|
+
mode: a.mode === 'permission' ? 'permission' : 'permission+question',
|
|
84
|
+
waitSeconds: waitSecondsFrom(a),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Whether the hook waits for a board decision on a marker of this kind
|
|
89
|
+
function isKindGated(kind, approvals) {
|
|
90
|
+
if (!approvals.enabled) return false;
|
|
91
|
+
if (kind === 'question') return approvals.mode === 'permission+question';
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Why the board may not answer this marker, or null when it may. Shared by the
|
|
96
|
+
// session list (which hides the buttons) and the respond route (which refuses),
|
|
97
|
+
// so the two can never disagree.
|
|
98
|
+
function boardRefusal(marker, approvals, now = Date.now()) {
|
|
99
|
+
if (!isKindGated(marker.kind, approvals)) {
|
|
100
|
+
return { status: 410, error: 'UI approvals are off for this ask — answer it in the terminal' };
|
|
101
|
+
}
|
|
102
|
+
if (isLapsed(marker.timestamp, approvals.waitSeconds * 1000, now)) {
|
|
103
|
+
return { status: 410, error: 'Ask lapsed — answer it in the terminal' };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
function isLapsed(timestamp, waitMs, now = Date.now()) {
|
|
78
109
|
if (!timestamp) return true;
|
|
79
110
|
return now - new Date(timestamp).getTime() > waitMs + LAPSE_GRACE_MS;
|
|
80
111
|
}
|
|
81
112
|
|
|
82
|
-
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed };
|
|
113
|
+
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, approvalsFrom, isKindGated, boardRefusal, isLapsed };
|
package/lib/inline-assets.js
CHANGED
package/lib/parsers.js
CHANGED
|
@@ -185,6 +185,14 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
185
185
|
label = base + formatTaskUsage(notif.usage);
|
|
186
186
|
safeText = notif.result || base;
|
|
187
187
|
}
|
|
188
|
+
// A subagent, teammate or peer session handing work back through SendMessage is
|
|
189
|
+
// also model output, not user input. Callers on the `origin` path pass the parts
|
|
190
|
+
// ready-made; the text paths (type:'user', queued enqueue) parse the envelope.
|
|
191
|
+
const agentMsg = extras?.agent || (notif ? null : parseAgentMessage(safeText));
|
|
192
|
+
if (agentMsg) {
|
|
193
|
+
label = agentMsg.label;
|
|
194
|
+
safeText = agentMsg.body;
|
|
195
|
+
}
|
|
188
196
|
const truncated = safeText.length > USER_TEXT_MAX;
|
|
189
197
|
const msg = {
|
|
190
198
|
type: 'user',
|
|
@@ -200,12 +208,26 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
200
208
|
if (notif.taskId) msg.taskId = notif.taskId;
|
|
201
209
|
if (notif.status) msg.taskStatus = notif.status;
|
|
202
210
|
}
|
|
211
|
+
if (agentMsg) {
|
|
212
|
+
msg.agentMessage = true;
|
|
213
|
+
if (agentMsg.from) msg.agentFrom = agentMsg.from;
|
|
214
|
+
}
|
|
203
215
|
if (extras) {
|
|
204
216
|
if (extras.uuid) msg.uuid = extras.uuid;
|
|
205
217
|
if (extras.images && extras.images.length) msg.images = extras.images;
|
|
206
218
|
if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
|
|
207
219
|
if (extras.queued) msg.queued = true;
|
|
208
220
|
}
|
|
221
|
+
// One agent message reaches the log twice: the queued enqueue record and the
|
|
222
|
+
// delivered one right after it. Keep the first and drop the twin here, so no
|
|
223
|
+
// renderer has to know about the double write.
|
|
224
|
+
if (agentMsg) {
|
|
225
|
+
const prev = messages[messages.length - 1];
|
|
226
|
+
if (prev?.agentMessage && (prev.agentFrom || null) === (msg.agentFrom || null) && prev.text === msg.text) {
|
|
227
|
+
if (!msg.queued) delete prev.queued;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
209
231
|
messages.push(msg);
|
|
210
232
|
}
|
|
211
233
|
|
|
@@ -587,6 +609,43 @@ function parseTaskNotification(text) {
|
|
|
587
609
|
};
|
|
588
610
|
}
|
|
589
611
|
|
|
612
|
+
// A message another agent sent into this session (a subagent hand-back, a teammate
|
|
613
|
+
// or a peer session using SendMessage) is model output, not user input. Turn the
|
|
614
|
+
// sender and the raw body into the chip label plus the body alone, so it never
|
|
615
|
+
// renders under a person icon.
|
|
616
|
+
function agentMessageParts(from, rawBody, handback) {
|
|
617
|
+
let body = (rawBody || '').trimEnd();
|
|
618
|
+
// A hand-back frames the report with the harness note that it carries no user
|
|
619
|
+
// authority, and indents every line of the report by two spaces so a forged
|
|
620
|
+
// frame at column zero is impossible. Strip the frame and that indent.
|
|
621
|
+
const framed = body.match(/^\[Subagent hand-back\][\s\S]*?report follows:\n([\s\S]*)$/);
|
|
622
|
+
if (framed) body = framed[1].replace(/^ {2}/gm, '').trim();
|
|
623
|
+
const handBack = !!framed || !!handback;
|
|
624
|
+
const kind = handBack ? 'Subagent hand-back' : 'Agent message';
|
|
625
|
+
// Harness agent ids are long hex; a named sender ("code-review") is shown whole.
|
|
626
|
+
const short = from && /^[0-9a-f]{12,}$/i.test(from) ? from.slice(0, 8) : from;
|
|
627
|
+
return { from: from || null, handBack, label: short ? `${kind} · ${short}` : kind, body };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The text delivery paths carry the body inside an <agent-message from="..."> envelope,
|
|
631
|
+
// with the harness notes outside it. The envelope must open the message (after the
|
|
632
|
+
// harness preamble, if any): prose that merely quotes the tag is a user message.
|
|
633
|
+
// The body is unescaped agent text that can quote the envelope it describes, so the
|
|
634
|
+
// real closing tag is the last one.
|
|
635
|
+
function parseAgentMessage(text) {
|
|
636
|
+
if (typeof text !== 'string' || !text.includes('<agent-message')) return null;
|
|
637
|
+
const start = text.indexOf('<agent-message');
|
|
638
|
+
const preamble = text.slice(0, start).trim();
|
|
639
|
+
if (preamble && !/^Another Claude session sent a message[^\n]*:$/.test(preamble)) return null;
|
|
640
|
+
const gt = text.indexOf('>', start);
|
|
641
|
+
const end = text.lastIndexOf('</agent-message>');
|
|
642
|
+
if (gt === -1 || end < gt) return null;
|
|
643
|
+
const from = (text.slice(start, gt).match(/from="([^"]*)"/) || [])[1] || null;
|
|
644
|
+
let bodyStart = gt + 1;
|
|
645
|
+
while (text[bodyStart] === '\n') bodyStart++;
|
|
646
|
+
return agentMessageParts(from, text.slice(bodyStart, end));
|
|
647
|
+
}
|
|
648
|
+
|
|
590
649
|
function getSystemMessageLabel(text) {
|
|
591
650
|
const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
|
|
592
651
|
if (taskMatch) return taskMatch[1].trim();
|
|
@@ -801,6 +860,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
801
860
|
messages.push(msg);
|
|
802
861
|
}
|
|
803
862
|
}
|
|
863
|
+
} else if (obj.type === 'user' && obj.origin?.kind === 'peer') {
|
|
864
|
+
// Claude Code >= 2.1 files the delivered copy as isMeta (dropped by the
|
|
865
|
+
// branch below) and repeats the envelope's fields on `origin`. That
|
|
866
|
+
// record is always written; the queued enqueue is not, so read it from
|
|
867
|
+
// here and let pushUserMessage drop whichever twin arrives second.
|
|
868
|
+
pushUserMessage(messages, '', obj.timestamp, null, {
|
|
869
|
+
uuid: obj.uuid,
|
|
870
|
+
agent: agentMessageParts(obj.origin.from, obj.origin.body, obj.origin.handback)
|
|
871
|
+
});
|
|
804
872
|
} else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
|
|
805
873
|
if (obj.isCompactSummary) {
|
|
806
874
|
// Surface the compaction summary as a single expandable "Compacted"
|
|
@@ -1296,6 +1364,112 @@ function buildAgentProgressMap(jsonlPath) {
|
|
|
1296
1364
|
return buildSessionDigest(jsonlPath).progressMap;
|
|
1297
1365
|
}
|
|
1298
1366
|
|
|
1367
|
+
const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
|
|
1368
|
+
|
|
1369
|
+
function toolResultText(content) {
|
|
1370
|
+
if (typeof content === 'string') return content;
|
|
1371
|
+
if (!Array.isArray(content)) return '';
|
|
1372
|
+
return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Two kinds of line record a published artifact: the `frame-link` entry the
|
|
1376
|
+
// viewer writes, which carries the title, and the result of an Artifact publish,
|
|
1377
|
+
// which is there even when the artifact was never opened in a frame. Keyed by URL,
|
|
1378
|
+
// so the republishes of one page collapse into a single entry. A result counts
|
|
1379
|
+
// only when its tool_use_id belongs to a publish call — an artifact URL quoted
|
|
1380
|
+
// back by a grep or a file read would otherwise register another session's page as
|
|
1381
|
+
// this session's own, and a `read` of someone else's artifact is not a publish.
|
|
1382
|
+
function readArtifactLinks(jsonlPath) {
|
|
1383
|
+
const byUrl = new Map();
|
|
1384
|
+
const publishedFileByToolUseId = new Map();
|
|
1385
|
+
const upsert = (url, ts) => {
|
|
1386
|
+
let a = byUrl.get(url);
|
|
1387
|
+
if (!a) {
|
|
1388
|
+
a = { url, title: null, path: null, firstSeen: ts || null };
|
|
1389
|
+
byUrl.set(url, a);
|
|
1390
|
+
}
|
|
1391
|
+
if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
|
|
1392
|
+
return a;
|
|
1393
|
+
};
|
|
1394
|
+
try {
|
|
1395
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
1396
|
+
for (const line of content.split('\n')) {
|
|
1397
|
+
if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
|
|
1398
|
+
continue;
|
|
1399
|
+
let obj;
|
|
1400
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1401
|
+
if (obj.type === 'frame-link' && obj.frameUrl) {
|
|
1402
|
+
const a = upsert(obj.frameUrl, obj.timestamp);
|
|
1403
|
+
if (obj.title) a.title = obj.title;
|
|
1404
|
+
if (obj.path) a.path = obj.path;
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
const blocks = obj?.message?.content;
|
|
1408
|
+
if (!Array.isArray(blocks)) continue;
|
|
1409
|
+
for (const b of blocks) {
|
|
1410
|
+
if (b?.type === 'tool_use') {
|
|
1411
|
+
const input = b.name === 'Artifact' ? b.input || {} : null;
|
|
1412
|
+
// The publish action is the default one, so it is spelled by the file it
|
|
1413
|
+
// takes rather than by a name.
|
|
1414
|
+
if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
|
|
1415
|
+
publishedFileByToolUseId.set(b.id, input.file_path);
|
|
1416
|
+
}
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
|
|
1420
|
+
const m = toolResultText(b.content).match(ARTIFACT_URL_RE);
|
|
1421
|
+
if (!m) continue;
|
|
1422
|
+
const a = upsert(m[0], obj.timestamp);
|
|
1423
|
+
if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
} catch (_) {}
|
|
1427
|
+
return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const SCRATCH_NEW_RE = /\bscratch new\b/;
|
|
1431
|
+
// `scratch new` reports where it put the pad; the two spaces are the CLI's own column
|
|
1432
|
+
// padding, which is what makes the prefilter below narrow enough to be worth having.
|
|
1433
|
+
const SCRATCH_MANIFEST_RE = /^\s*manifest\s*:\s*(.+?)\s*$/m;
|
|
1434
|
+
// The name must sit immediately after `new`, and the bare-word branch must refuse a
|
|
1435
|
+
// leading `-`, so that a flag or a flag's value cannot be read as the name.
|
|
1436
|
+
const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
|
|
1437
|
+
|
|
1438
|
+
// The `scratch new` calls in a transcript, `{ ts, path, name }` per call. `path` is null
|
|
1439
|
+
// whenever the command piped the CLI's output away — the common case, not an edge one:
|
|
1440
|
+
// the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
|
|
1441
|
+
// stands in because it is what the manifest stores as its own `name`; the path is not
|
|
1442
|
+
// recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
|
|
1443
|
+
function readScratchpadCreations(jsonlPath) {
|
|
1444
|
+
const calls = new Map();
|
|
1445
|
+
try {
|
|
1446
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
1447
|
+
for (const line of content.split('\n')) {
|
|
1448
|
+
// Substring reject before JSON.parse: transcripts reach tens of megabytes and
|
|
1449
|
+
// only a handful of lines can ever match.
|
|
1450
|
+
const isCall = line.includes('scratch new');
|
|
1451
|
+
if (!isCall && !line.includes('manifest :')) continue;
|
|
1452
|
+
let obj;
|
|
1453
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1454
|
+
const blocks = obj?.message?.content;
|
|
1455
|
+
if (!Array.isArray(blocks)) continue;
|
|
1456
|
+
const ts = Date.parse(obj.timestamp);
|
|
1457
|
+
for (const b of blocks) {
|
|
1458
|
+
if (b?.type === 'tool_use' && b.name === 'Bash' && SCRATCH_NEW_RE.test(b.input?.command || '')) {
|
|
1459
|
+
if (b.id && Number.isFinite(ts)) {
|
|
1460
|
+
const named = SCRATCH_NAME_RE.exec(b.input.command);
|
|
1461
|
+
calls.set(b.id, { ts, path: null, name: named?.slice(1).find(Boolean) ?? null });
|
|
1462
|
+
}
|
|
1463
|
+
} else if (b?.type === 'tool_result' && calls.has(b.tool_use_id)) {
|
|
1464
|
+
const m = SCRATCH_MANIFEST_RE.exec(toolResultText(b.content));
|
|
1465
|
+
if (m) calls.get(b.tool_use_id).path = m[1];
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
} catch (_) {}
|
|
1470
|
+
return [...calls.values()];
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1299
1473
|
function readCompactSummaries(jsonlPath) {
|
|
1300
1474
|
const results = [];
|
|
1301
1475
|
// Inline format: newer Claude Code stores the summary directly in the parent
|
|
@@ -1621,6 +1795,7 @@ module.exports = {
|
|
|
1621
1795
|
parseSessionsIndex,
|
|
1622
1796
|
parseJsonlLine,
|
|
1623
1797
|
parseTaskNotification,
|
|
1798
|
+
parseAgentMessage,
|
|
1624
1799
|
getSystemMessageLabel,
|
|
1625
1800
|
readSessionInfoFromJsonl,
|
|
1626
1801
|
readRecentMessages,
|
|
@@ -1634,6 +1809,8 @@ module.exports = {
|
|
|
1634
1809
|
buildAgentProgressMap,
|
|
1635
1810
|
buildSessionDigest,
|
|
1636
1811
|
readCompactSummaries,
|
|
1812
|
+
readArtifactLinks,
|
|
1813
|
+
readScratchpadCreations,
|
|
1637
1814
|
findTerminatedTeammates,
|
|
1638
1815
|
extractPromptFromTranscript,
|
|
1639
1816
|
extractModelFromTranscript,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs').promises;
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { isContained } = require('./contain');
|
|
6
|
+
|
|
7
|
+
// Resolves a folder inside a session's scratchpad dir from the path the client sends
|
|
8
|
+
// back — the absolute path a listing handed out, or one relative to the root. Returns
|
|
9
|
+
// null when it escapes the root (`..`, another dir, a symlink out), so the endpoint
|
|
10
|
+
// never lists anything the root did not hand out.
|
|
11
|
+
function resolveScratchSubdir(root, sub) {
|
|
12
|
+
if (!sub) return root;
|
|
13
|
+
if (typeof sub !== 'string') return null;
|
|
14
|
+
const full = path.resolve(root, sub);
|
|
15
|
+
return isContained(full, root) ? full : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// One level of a scratchpad dir, newest first whatever the kind. Not recursive on
|
|
19
|
+
// purpose: sessions drop clones and build output in here, and the client asks for a
|
|
20
|
+
// folder's children only when the user opens it.
|
|
21
|
+
async function listScratchDir(dir) {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const rows = await Promise.all(
|
|
29
|
+
entries
|
|
30
|
+
.filter((entry) => entry.isFile() || entry.isDirectory())
|
|
31
|
+
.map(async (entry) => {
|
|
32
|
+
const full = path.join(dir, entry.name);
|
|
33
|
+
try {
|
|
34
|
+
const { mtimeMs } = await fs.stat(full);
|
|
35
|
+
return { name: entry.name, path: full, kind: entry.isDirectory() ? 'dir' : 'file', mtimeMs };
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
return rows
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
44
|
+
.map(({ mtimeMs, ...row }) => ({ ...row, modifiedAt: new Date(mtimeMs).toISOString() }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { resolveScratchSubdir, listScratchDir };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Blocking approval gate: lets the cck board answer a permission ask or an
|
|
3
3
|
# AskUserQuestion. Always writes the _waiting.json marker first (badge behavior
|
|
4
|
-
# is unchanged when the feature is off), then —
|
|
5
|
-
# the board's server is alive — waits for a decision file written by the server.
|
|
4
|
+
# is unchanged when the feature is off), then — unless config.json opts out, and
|
|
5
|
+
# only while the board's server is alive — waits for a decision file written by the server.
|
|
6
6
|
#
|
|
7
7
|
# Contract (_plans/cck-ui-approvals/decisions.md), rooted at <CLAUDE_CONFIG_DIR or ~/.claude>/.cck:
|
|
8
8
|
# marker agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
|
|
9
9
|
# decision agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
|
|
10
|
-
# config
|
|
10
|
+
# config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
|
|
11
11
|
# liveness server.json {port, pid} (D1: a dead board costs nothing)
|
|
12
12
|
#
|
|
13
13
|
# First writer wins (D5): a terminal answer deletes the marker via PostToolUse
|
|
@@ -61,37 +61,58 @@ echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" --arg id "$REQ_ID" '{
|
|
|
61
61
|
# Every exit below leaves the marker in place for the badge; agent-spy.sh's
|
|
62
62
|
# PostToolUse (or the server's TTL) retires it, exactly as before this feature.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ENABLED=""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
# Defaults mirror lib/approvals.js — keep in sync. A missing or unparseable
|
|
65
|
+
# config.json means defaults, i.e. the gate is on.
|
|
66
|
+
ENABLED="true"
|
|
67
|
+
MODE="permission+question"
|
|
68
|
+
WAIT_SECONDS=""
|
|
69
|
+
CONFIG="$CCK_DIR/config.json"
|
|
70
|
+
if [ -f "$CONFIG" ]; then
|
|
71
|
+
eval "$(jq -r '
|
|
72
|
+
@sh "ENABLED=\(if .approvals.enabled == false then "false" else "true" end)",
|
|
73
|
+
@sh "MODE=\(.approvals.mode // "permission+question")",
|
|
74
|
+
@sh "WAIT_SECONDS=\(.approvals.waitSeconds // "")"
|
|
75
|
+
' < "$CONFIG" 2>/dev/null)"
|
|
76
|
+
fi
|
|
77
|
+
[ "$ENABLED" = "false" ] && exit 0
|
|
73
78
|
|
|
74
79
|
if [ "$KIND" = "question" ] && [ "$MODE" != "permission+question" ]; then
|
|
75
80
|
exit 0
|
|
76
81
|
fi
|
|
77
82
|
|
|
78
|
-
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=
|
|
83
|
+
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=1800 ;; esac
|
|
79
84
|
# PERMISSION_TTL_MS hides the card at 30 min — waiting longer than the UI can
|
|
80
85
|
# show the ask is strictly worse than giving up (D11)
|
|
81
86
|
[ "$WAIT_SECONDS" -gt 1800 ] && WAIT_SECONDS=1800
|
|
82
87
|
|
|
83
88
|
SERVER_INFO="$CCK_DIR/server.json"
|
|
84
|
-
|
|
85
|
-
SERVER_PORT=$(jq -r '.port // empty' < "$SERVER_INFO" 2>/dev/null)
|
|
86
|
-
[ -n "$SERVER_PORT" ] || exit 0
|
|
89
|
+
|
|
87
90
|
# A TCP connect beats a pid probe: it proves the board is actually serving, and
|
|
88
91
|
# it works in the stripped environment Claude Code spawns hooks into, where
|
|
89
|
-
# kill -0 cannot see native Windows pids and ps may be missing from PATH
|
|
90
|
-
|
|
92
|
+
# kill -0 cannot see native Windows pids and ps may be missing from PATH. The
|
|
93
|
+
# port is re-read on every probe, not cached: under the hub every sub-app binds
|
|
94
|
+
# an ephemeral port, so a board that restarts mid-wait comes back on a new one.
|
|
95
|
+
# $(<file) and =~ keep the probe free of jq and cat spawns (~280 ms each here).
|
|
96
|
+
board_alive() {
|
|
97
|
+
local raw port
|
|
98
|
+
raw=$(<"$SERVER_INFO") 2>/dev/null || return 1
|
|
99
|
+
[[ $raw =~ \"port\"[[:space:]]*:[[:space:]]*([0-9]+) ]] || return 1
|
|
100
|
+
port=${BASH_REMATCH[1]}
|
|
101
|
+
(: < "/dev/tcp/127.0.0.1/$port") 2>/dev/null
|
|
102
|
+
}
|
|
91
103
|
|
|
92
104
|
DECISION="$DIR/_decision-$REQ_ID.json"
|
|
93
105
|
# EPOCHSECONDS (bash 5) keeps the poll loop free of `date` spawns
|
|
94
106
|
DEADLINE=$((EPOCHSECONDS + WAIT_SECONDS))
|
|
107
|
+
# Probing once up front made one unreachable moment disarm the whole ask, so a
|
|
108
|
+
# board restarting on a new port (every hub-spawned sub-app binds an ephemeral
|
|
109
|
+
# one) took the ask with it. Probe on a cadence instead and only give up once
|
|
110
|
+
# the board has been gone for the whole grace. The terminal prompt stays live
|
|
111
|
+
# throughout either way.
|
|
112
|
+
BOARD_PROBE_SECONDS=5
|
|
113
|
+
BOARD_GRACE_SECONDS=15
|
|
114
|
+
NEXT_PROBE=0
|
|
115
|
+
UNREACHABLE_SINCE=0
|
|
95
116
|
|
|
96
117
|
while :; do
|
|
97
118
|
if [ -f "$DECISION" ]; then
|
|
@@ -136,6 +157,20 @@ while :; do
|
|
|
136
157
|
IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
|
|
137
158
|
case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
|
|
138
159
|
|
|
160
|
+
if [ "$EPOCHSECONDS" -ge "$NEXT_PROBE" ]; then
|
|
161
|
+
NEXT_PROBE=$((EPOCHSECONDS + BOARD_PROBE_SECONDS))
|
|
162
|
+
# No beacon at all = no board on this config dir, now or a moment ago: give
|
|
163
|
+
# up at once, exactly as before. A beacon whose port is closed is the
|
|
164
|
+
# restart window instead, so that one gets the grace.
|
|
165
|
+
[ -f "$SERVER_INFO" ] || exit 0
|
|
166
|
+
if board_alive; then
|
|
167
|
+
UNREACHABLE_SINCE=0
|
|
168
|
+
else
|
|
169
|
+
[ "$UNREACHABLE_SINCE" -eq 0 ] && UNREACHABLE_SINCE=$EPOCHSECONDS
|
|
170
|
+
[ $((EPOCHSECONDS - UNREACHABLE_SINCE)) -ge "$BOARD_GRACE_SECONDS" ] && exit 0
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
|
|
139
174
|
[ "$EPOCHSECONDS" -ge "$DEADLINE" ] && exit 0
|
|
140
175
|
sleep 0.5
|
|
141
176
|
done
|