claude-code-kanban 6.1.2 → 7.0.1
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 +3 -2
- package/cli.js +200 -164
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/retention.js +14 -8
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +30 -22
- package/public/app.js +940 -111
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +452 -8
- package/public/tree-highlight.js +74 -0
- package/server.js +133 -40
- package/skill-guides/dispatch.md +1 -1
package/lib/panes.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Per-session pane layout: the tabs next to Board. The file is the only copy, shared by every
|
|
2
|
+
// board process on the config dir, the CLI and the agent. Which tab is on screen is each
|
|
3
|
+
// browser tab's own state, not stored here. Expiry: docs/retention.md.
|
|
4
|
+
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { httpError } = require('./http-error');
|
|
7
|
+
const { canonicalPath } = require('./linked-docs');
|
|
8
|
+
const { isLoopbackAddress } = require('./net-guard');
|
|
9
|
+
const { oneLine } = require('./one-line');
|
|
10
|
+
const { pruneMap } = require('./retention');
|
|
11
|
+
|
|
12
|
+
const MAX_PANES = 50;
|
|
13
|
+
const MAX_TITLE = 100;
|
|
14
|
+
const KINDS = new Set(['url', 'html', 'markdown', 'text', 'image']);
|
|
15
|
+
|
|
16
|
+
const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
|
|
17
|
+
|
|
18
|
+
// A frame with allow-scripts plus allow-same-origin on the board's origin or the hub's can reach
|
|
19
|
+
// into its parent and drop its own sandbox. Every name either one is served under counts.
|
|
20
|
+
function isOwnOrigin(url, { boardPort, boardHosts = [], hubUrl }) {
|
|
21
|
+
let u;
|
|
22
|
+
try {
|
|
23
|
+
u = new URL(url);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const port = portOf(u);
|
|
28
|
+
const loopback = isLoopbackAddress(u.hostname);
|
|
29
|
+
if (u.protocol === 'http:' && port === boardPort && (loopback || boardHosts.includes(u.hostname))) return true;
|
|
30
|
+
let hub;
|
|
31
|
+
try {
|
|
32
|
+
hub = new URL(hubUrl);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const hubHost = u.hostname === hub.hostname || (loopback && isLoopbackAddress(hub.hostname));
|
|
37
|
+
return u.protocol === hub.protocol && port === portOf(hub) && hubHost;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
|
|
41
|
+
|
|
42
|
+
const targetKey = (kind, target) => (kind === 'url' ? `url:${target}` : `file:${canonicalPath(target)}`);
|
|
43
|
+
|
|
44
|
+
function defaultTitle(kind, target) {
|
|
45
|
+
if (kind !== 'url') return path.basename(target);
|
|
46
|
+
try {
|
|
47
|
+
return new URL(target).host;
|
|
48
|
+
} catch {
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const idNumber = (id) => Number(id.slice(1)) || 0;
|
|
54
|
+
|
|
55
|
+
function cleanLayout(raw) {
|
|
56
|
+
if (!raw || !Array.isArray(raw.panes) || !Number.isFinite(raw.updatedAt)) return null;
|
|
57
|
+
const ids = new Set();
|
|
58
|
+
const panes = raw.panes.filter((p) => {
|
|
59
|
+
const ok = p && typeof p.id === 'string' && !ids.has(p.id) && KINDS.has(p.kind) && typeof p.target === 'string' && p.target;
|
|
60
|
+
if (ok) ids.add(p.id);
|
|
61
|
+
return ok;
|
|
62
|
+
});
|
|
63
|
+
const nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, ...panes.map((p) => idNumber(p.id) + 1));
|
|
64
|
+
const rev = Number.isInteger(raw.rev) && raw.rev > 0 ? raw.rev : 1;
|
|
65
|
+
return { rev, nextId, panes: panes.slice(0, MAX_PANES), updatedAt: raw.updatedAt };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const publicLayout = (l) => ({ rev: l.rev, panes: l.panes.map((p) => ({ ...p })), updatedAt: l.updatedAt });
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every write re-reads the file first, so two boards on one config dir add to each other's
|
|
72
|
+
* layouts instead of overwriting them. The read-to-rename window is left open: both writers
|
|
73
|
+
* are one user's own board tabs and CLI calls.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} o
|
|
76
|
+
* @param {() => object|null|undefined} o.load returns `{version: 2, sessions: {[id]: {rev, nextId, panes, updatedAt}}}`,
|
|
77
|
+
* null when there is no file, or undefined when the file has not changed since the last load or save
|
|
78
|
+
* @param {(data: object) => void} o.save
|
|
79
|
+
* @param {(sessionId: string, layout: object) => void} [o.onChange] called for each layout whose rev
|
|
80
|
+
* moved, from this store's writes and from `reload()` alike
|
|
81
|
+
*/
|
|
82
|
+
function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
|
|
83
|
+
let bySession = new Map();
|
|
84
|
+
|
|
85
|
+
function reload() {
|
|
86
|
+
const data = load();
|
|
87
|
+
if (data === undefined) return;
|
|
88
|
+
const next = new Map();
|
|
89
|
+
const saved = data?.sessions;
|
|
90
|
+
if (saved && typeof saved === 'object') {
|
|
91
|
+
for (const [id, raw] of Object.entries(saved)) {
|
|
92
|
+
const layout = cleanLayout(raw);
|
|
93
|
+
if (layout) next.set(id, layout);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const before = bySession;
|
|
97
|
+
bySession = next;
|
|
98
|
+
for (const [id, layout] of next) {
|
|
99
|
+
if (before.get(id)?.rev !== layout.rev) onChange(id, publicLayout(layout));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
reload();
|
|
103
|
+
|
|
104
|
+
const persist = () => save({ version: 2, sessions: Object.fromEntries(bySession) });
|
|
105
|
+
|
|
106
|
+
function write(sessionId, layout) {
|
|
107
|
+
layout.rev += 1;
|
|
108
|
+
layout.updatedAt = now();
|
|
109
|
+
bySession.set(sessionId, layout);
|
|
110
|
+
persist();
|
|
111
|
+
onChange(sessionId, publicLayout(layout));
|
|
112
|
+
return publicLayout(layout);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function get(sessionId) {
|
|
116
|
+
const layout = bySession.get(sessionId);
|
|
117
|
+
return publicLayout(layout || emptyLayout());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
|
|
121
|
+
function add(sessionId, { kind, target, title }) {
|
|
122
|
+
if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
|
|
123
|
+
reload();
|
|
124
|
+
const layout = bySession.get(sessionId) || emptyLayout();
|
|
125
|
+
const key = targetKey(kind, target);
|
|
126
|
+
const existing = layout.panes.find((p) => targetKey(p.kind, p.target) === key);
|
|
127
|
+
if (existing) return { pane: { ...existing }, added: false, layout: publicLayout(layout) };
|
|
128
|
+
if (layout.panes.length >= MAX_PANES) throw httpError(409, `A session holds at most ${MAX_PANES} panes`, 'pane_limit');
|
|
129
|
+
const pane = {
|
|
130
|
+
id: `p${layout.nextId++}`,
|
|
131
|
+
kind,
|
|
132
|
+
target,
|
|
133
|
+
title: oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE),
|
|
134
|
+
addedAt: now(),
|
|
135
|
+
};
|
|
136
|
+
layout.panes.push(pane);
|
|
137
|
+
return { pane: { ...pane }, added: true, layout: write(sessionId, layout) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Returns the new layout, or null when the session has no such pane. An emptied layout stays
|
|
141
|
+
// until retention drops it, so its ids and rev keep counting up.
|
|
142
|
+
function remove(sessionId, paneId) {
|
|
143
|
+
reload();
|
|
144
|
+
const layout = bySession.get(sessionId);
|
|
145
|
+
const i = layout ? layout.panes.findIndex((p) => p.id === paneId) : -1;
|
|
146
|
+
if (i < 0) return null;
|
|
147
|
+
layout.panes.splice(i, 1);
|
|
148
|
+
return write(sessionId, layout);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `order` is every pane id of the session, in the new tab order.
|
|
152
|
+
function reorder(sessionId, order) {
|
|
153
|
+
reload();
|
|
154
|
+
const layout = bySession.get(sessionId);
|
|
155
|
+
const ids = layout ? layout.panes.map((p) => p.id) : [];
|
|
156
|
+
const valid = Array.isArray(order) && order.length === ids.length && new Set(order).size === ids.length;
|
|
157
|
+
if (!valid || !order.every((id) => ids.includes(id))) throw httpError(400, 'order must list every pane id once', 'bad_order');
|
|
158
|
+
if (!layout || order.every((id, i) => id === ids[i])) return get(sessionId);
|
|
159
|
+
layout.panes = order.map((id) => layout.panes.find((p) => p.id === id));
|
|
160
|
+
return write(sessionId, layout);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Returns how many layouts went; writes only when one did.
|
|
164
|
+
function prune({ known, maxAgeMs }) {
|
|
165
|
+
reload();
|
|
166
|
+
const removed = pruneMap(bySession, (l) => l.updatedAt, { known, maxAgeMs, now: now() });
|
|
167
|
+
if (removed) persist();
|
|
168
|
+
return removed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { get, add, remove, reorder, prune, reload };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
|
package/lib/retention.js
CHANGED
|
@@ -26,6 +26,18 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
|
|
|
26
26
|
return (known && !known.has(id)) || age > maxAgeMs;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Deletes the expired entries of a session-keyed map and returns how many went.
|
|
30
|
+
function pruneMap(map, atOf, { known, maxAgeMs, now }) {
|
|
31
|
+
let removed = 0;
|
|
32
|
+
for (const [id, entry] of map) {
|
|
33
|
+
if (isExpired(id, atOf(entry), { known, maxAgeMs, now })) {
|
|
34
|
+
map.delete(id);
|
|
35
|
+
removed++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return removed;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Sessions started through `dispatch start`, kept after their terminal ends so the card
|
|
31
43
|
* keeps its marker.
|
|
@@ -55,14 +67,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
55
67
|
|
|
56
68
|
// Returns how many entries went; writes only when one did.
|
|
57
69
|
function prune({ known, maxAgeMs }) {
|
|
58
|
-
const
|
|
59
|
-
let removed = 0;
|
|
60
|
-
for (const [id, e] of entries) {
|
|
61
|
-
if (isExpired(id, e.at, { known, maxAgeMs, now: t })) {
|
|
62
|
-
entries.delete(id);
|
|
63
|
-
removed++;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
70
|
+
const removed = pruneMap(entries, (e) => e.at, { known, maxAgeMs, now: now() });
|
|
66
71
|
if (removed) persist();
|
|
67
72
|
return removed;
|
|
68
73
|
}
|
|
@@ -159,6 +164,7 @@ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max
|
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
module.exports = {
|
|
167
|
+
pruneMap,
|
|
162
168
|
createDispatchedStore,
|
|
163
169
|
scanTranscripts,
|
|
164
170
|
pruneSessionDirs,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where the harness puts a session's scratchpad. The rule and its probes: docs/session-scanning.md.
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { existsSync, opendirSync } = require('node:fs');
|
|
5
|
+
const { encodeProjectDirName } = require('./claude-dir');
|
|
6
|
+
|
|
7
|
+
function isEmptyDir(dir) {
|
|
8
|
+
let handle;
|
|
9
|
+
try {
|
|
10
|
+
handle = opendirSync(dir);
|
|
11
|
+
return handle.readSync() === null;
|
|
12
|
+
} catch {
|
|
13
|
+
return true;
|
|
14
|
+
} finally {
|
|
15
|
+
handle?.closeSync();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createScratchpadDirResolver({ root, resolveWorktree, exists = existsSync, isEmpty = isEmptyDir }) {
|
|
20
|
+
// A by-project dir that holds an entry always wins, so that answer is kept and never probed again.
|
|
21
|
+
const settled = new Map();
|
|
22
|
+
|
|
23
|
+
return function getScratchpadDir(id, meta) {
|
|
24
|
+
if (!meta.jsonlPath) return null;
|
|
25
|
+
const byProject = path.join(root, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
|
|
26
|
+
if (settled.get(id) === byProject) return byProject;
|
|
27
|
+
|
|
28
|
+
// Claude Code keys the scratchpad on the process's original cwd, while entering a worktree
|
|
29
|
+
// moves the transcript under the worktree.
|
|
30
|
+
const wt = resolveWorktree(meta.project);
|
|
31
|
+
const launch = wt ? wt.repo : meta.project;
|
|
32
|
+
if (!launch) return byProject;
|
|
33
|
+
const byLaunch = path.join(root, encodeProjectDirName(launch), id, 'scratchpad');
|
|
34
|
+
if (byLaunch === byProject || !exists(byLaunch)) return byProject;
|
|
35
|
+
if (isEmpty(byProject)) return byLaunch;
|
|
36
|
+
settled.set(id, byProject);
|
|
37
|
+
return byProject;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { createScratchpadDirResolver };
|
package/lib/session-events.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
4
|
// grows one permanent entry per session id ever named in a request path.
|
|
5
5
|
|
|
6
|
+
const { oneLine } = require('./one-line');
|
|
7
|
+
|
|
6
8
|
// Tells a live session that the board moved one of its tasks. The postman monitor
|
|
7
9
|
// (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
|
|
8
10
|
// each line, which Claude Code delivers into that session as a task notification.
|
|
@@ -24,10 +26,7 @@ const sessionEventBuckets = new Map();
|
|
|
24
26
|
//
|
|
25
27
|
// One cap for the whole line rather than one per field, because the description comes
|
|
26
28
|
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
27
|
-
|
|
28
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
29
|
-
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
30
|
-
}
|
|
29
|
+
const sanitizeEventLine = (line) => oneLine(line, 1500);
|
|
31
30
|
|
|
32
31
|
// Lines are plain sentences so they still read right once the skill that explains them has
|
|
33
32
|
// left the agent's context. EVENT_PREFIX marks the board as the sender.
|
package/lib/terminal.js
CHANGED
|
@@ -17,6 +17,7 @@ const fs = require('node:fs');
|
|
|
17
17
|
const os = require('node:os');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { isContained } = require('./contain');
|
|
20
|
+
const { isPidAlive } = require('./live-sessions');
|
|
20
21
|
const priority = require('./priority');
|
|
21
22
|
|
|
22
23
|
const WS_PATH = '/api/terminal/ws';
|
|
@@ -57,6 +58,8 @@ const RESTORE_GAP_MS = 2000;
|
|
|
57
58
|
const SAVE_DELAY_MS = 1000;
|
|
58
59
|
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
59
60
|
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
61
|
+
// Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
|
|
62
|
+
const CLAUDE_EXIT_POLL_MS = 5000;
|
|
60
63
|
// The size of a PTY no browser has attached to yet; the first attach resizes it.
|
|
61
64
|
const HEADLESS_COLS = 120;
|
|
62
65
|
const HEADLESS_ROWS = 32;
|
|
@@ -315,6 +318,10 @@ function createTerminalService(o) {
|
|
|
315
318
|
let lastSaved = null;
|
|
316
319
|
const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
|
|
317
320
|
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
321
|
+
const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
|
|
322
|
+
const pidAlive = o.isPidAlive || isPidAlive;
|
|
323
|
+
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
324
|
+
exitTimer.unref();
|
|
318
325
|
// Read before any terminal starts, so the first save keeps the ids still waiting to restore.
|
|
319
326
|
const saved = config.restore ? o.load?.()?.sessions : null;
|
|
320
327
|
const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
|
|
@@ -359,7 +366,7 @@ function createTerminalService(o) {
|
|
|
359
366
|
function saveState() {
|
|
360
367
|
saveTimer = null;
|
|
361
368
|
const ids = new Set(restoreQueue);
|
|
362
|
-
for (const s of sessions.values()) if (!s.ended && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
|
|
369
|
+
for (const s of sessions.values()) if (!s.ended && !s.claudeExitedAt && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
|
|
363
370
|
const data = { sessions: [...ids] };
|
|
364
371
|
const json = JSON.stringify(data);
|
|
365
372
|
if (json === lastSaved) return;
|
|
@@ -435,11 +442,12 @@ function createTerminalService(o) {
|
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
// The process a viewer types into: claude, found through its registry entry, or the shell.
|
|
438
|
-
function inputPid(s) {
|
|
445
|
+
function inputPid(s, registry) {
|
|
439
446
|
if (s.mode === 'shell') return s.pty.pid;
|
|
447
|
+
if (s.claudeExitedAt) return null;
|
|
440
448
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
441
|
-
const live = o.liveSessions();
|
|
442
|
-
const own = live
|
|
449
|
+
const live = registry || o.liveSessions();
|
|
450
|
+
const own = ownEntry(live, s, s.startedAt);
|
|
443
451
|
if (own || s.mode !== 'fork') {
|
|
444
452
|
s.claudePid = own?.pid;
|
|
445
453
|
return s.claudePid;
|
|
@@ -449,6 +457,36 @@ function createTerminalService(o) {
|
|
|
449
457
|
return s.claudePid;
|
|
450
458
|
}
|
|
451
459
|
|
|
460
|
+
// Claude deletes its registry entry on exit, and a crash leaves one with a dead pid. A terminal
|
|
461
|
+
// with no pid yet is still starting. Matching by pid keeps a /clear or /resume, which rewrite
|
|
462
|
+
// the entry's sessionId, from reading as an exit.
|
|
463
|
+
function checkExits() {
|
|
464
|
+
const watched = [...sessions.values()].filter((s) => !s.exited && !s.ended && s.mode !== 'shell');
|
|
465
|
+
if (!watched.length) return;
|
|
466
|
+
const live = o.liveSessions();
|
|
467
|
+
let moved = false;
|
|
468
|
+
for (const s of watched) {
|
|
469
|
+
if (s.claudeExitedAt) {
|
|
470
|
+
const back = ownEntry(live, s, s.claudeExitedAt);
|
|
471
|
+
if (!back || !pidAlive(back.pid)) continue;
|
|
472
|
+
s.claudeExitedAt = null;
|
|
473
|
+
s.claudePid = back.pid;
|
|
474
|
+
moved = true;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const pid = inputPid(s, live);
|
|
478
|
+
if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
|
|
479
|
+
s.claudeExitedAt = Date.now();
|
|
480
|
+
s.claudePid = null;
|
|
481
|
+
moved = true;
|
|
482
|
+
}
|
|
483
|
+
if (moved) changed();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function ownEntry(live, s, since) {
|
|
487
|
+
return live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= since - PICK_START_SLACK_MS);
|
|
488
|
+
}
|
|
489
|
+
|
|
452
490
|
// claude writes its registry entry a moment after it starts, so the pid is polled for.
|
|
453
491
|
function syncBoost(s) {
|
|
454
492
|
const want = s.sockets.size > 0 && !s.exited;
|
|
@@ -699,9 +737,10 @@ function createTerminalService(o) {
|
|
|
699
737
|
|
|
700
738
|
function claudePids() {
|
|
701
739
|
const out = {};
|
|
740
|
+
const live = sessions.size ? o.liveSessions() : [];
|
|
702
741
|
for (const s of sessions.values()) {
|
|
703
742
|
if (s.ended || s.exited || s.mode === 'shell') continue;
|
|
704
|
-
const pid = inputPid(s);
|
|
743
|
+
const pid = inputPid(s, live);
|
|
705
744
|
if (pid) out[s.id] = pid;
|
|
706
745
|
}
|
|
707
746
|
return out;
|
|
@@ -741,7 +780,7 @@ function createTerminalService(o) {
|
|
|
741
780
|
// pending queued prompt owns the input box until it is sent.
|
|
742
781
|
function paste(id, text) {
|
|
743
782
|
const s = sessions.get(id);
|
|
744
|
-
if (!s || s.ended || s.exited || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
783
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
745
784
|
if (onTrustScreen(s)) return false;
|
|
746
785
|
s.pty.write(bracketed(text));
|
|
747
786
|
return true;
|
|
@@ -750,6 +789,7 @@ function createTerminalService(o) {
|
|
|
750
789
|
function shutdown() {
|
|
751
790
|
shuttingDown = true;
|
|
752
791
|
clearTimeout(saveTimer);
|
|
792
|
+
clearInterval(exitTimer);
|
|
753
793
|
for (const s of sessions.values()) {
|
|
754
794
|
try { s.pty.kill(); } catch { /* already gone */ }
|
|
755
795
|
}
|
package/lib/worktrees.js
CHANGED
|
@@ -10,6 +10,11 @@ const { encodeProjectDirName } = require('./claude-dir');
|
|
|
10
10
|
// main checkout is readable without spawning git. Path shape alone would not do: only some
|
|
11
11
|
// worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
|
|
12
12
|
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
13
|
+
// A submodule's worktree points into the superproject: `<super>/.git/modules/<sub>/worktrees/<name>`,
|
|
14
|
+
// nested as `<a>/modules/<b>`. The checkout is taken to sit at `<super>/<sub>`, which holds while
|
|
15
|
+
// the submodule's name is its path, the git default; reading `core.worktree` would cost a read.
|
|
16
|
+
const GITDIR_SUBMODULE_WORKTREE_RE =
|
|
17
|
+
/^gitdir:\s*(.*)[/\\]\.git[/\\]modules[/\\](.+?)[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
13
18
|
// Where `claude -w` puts a worktree. Used only once the `.git` file is gone, because Claude
|
|
14
19
|
// Code removes worktrees while their transcripts stay.
|
|
15
20
|
const CLAUDE_WORKTREE_RE = /^(.*)[/\\]\.claude[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
@@ -20,9 +25,13 @@ function readGitFile(dir) {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function fromGitFile(dir, text) {
|
|
23
|
-
const
|
|
28
|
+
const pointer = text.trim();
|
|
29
|
+
const m = GITDIR_WORKTREE_RE.exec(pointer);
|
|
24
30
|
// Git writes the pointer with forward slashes on Windows; the project path uses the OS spelling.
|
|
25
|
-
|
|
31
|
+
if (m) return { repo: path.resolve(dir, m[1]), name: m[2] };
|
|
32
|
+
const sub = GITDIR_SUBMODULE_WORKTREE_RE.exec(pointer);
|
|
33
|
+
if (!sub) return null;
|
|
34
|
+
return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
function fromPathShape(dir) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.1",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -1,37 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
|
-
description:
|
|
4
|
-
argument-hint: '[
|
|
3
|
+
description: Kanban board CLI — show the user a file or page, link a doc or PR, add a pane, open or pin a session, find a session's transcript, list tasks.
|
|
4
|
+
argument-hint: '[doc|pane|session|task|project|dispatch] <subcommand> [target]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban Skill
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The skill is a thin wrapper over the `claude-code-kanban` CLI: the argument is a command and its subcommand, such as `doc link plan.md` or `session pin`. Run the bare binary, or `npx claude-code-kanban` when it is off PATH or the user asks for npx.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$CLAUDE_CODE_SESSION_ID`, so pass the flag only for another session.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## Commands
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
claude-code-kanban help # every command
|
|
17
|
-
claude-code-kanban help <command> # its subcommands
|
|
18
|
-
claude-code-kanban help <command> <subcommand> # flags and examples
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
| Argument | Command |
|
|
15
|
+
| Command | Subcommands |
|
|
22
16
|
|---|---|
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
|
|
17
|
+
| `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
|
|
18
|
+
| `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
|
|
19
|
+
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` |
|
|
20
|
+
| `task` | `list` for a session, a project or the whole board |
|
|
21
|
+
| `project` | `list` |
|
|
22
|
+
| `dispatch` | `start`, `list` — run `/claude-code-kanban:dispatch` instead |
|
|
23
|
+
|
|
24
|
+
## Run a command
|
|
25
|
+
|
|
26
|
+
1. Read `claude-code-kanban help <command> <subcommand>` for the flags. It matches the installed binary, so its flags are the ones that work.
|
|
27
|
+
2. Run the command and report its output line to the user.
|
|
28
|
+
|
|
29
|
+
With no argument, run nothing on the board: show the user the table above.
|
|
30
|
+
|
|
31
|
+
## Keep the board still
|
|
32
|
+
|
|
33
|
+
The user works in other windows while you run, so pick the quiet command. `doc link` and `pane add` add an entry the user opens when ready. `doc preview` is the one command that opens a modal on the user's screen: use it when the user asks to see something now.
|
|
34
|
+
|
|
35
|
+
To show the user a page, a prototype or a collage of screenshots, write it as HTML and link or preview that file. The preview renders HTML as authored in a sandboxed iframe and inlines its local stylesheets, scripts and images, so the file alone is enough.
|
|
36
|
+
|
|
37
|
+
## Another session
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
Read its transcript. `session view <id>` and `session search <text> --json` print the path.
|
|
32
40
|
|
|
33
|
-
To
|
|
41
|
+
To have card moves arrive in this session as instructions, the user types `/claude-code-kanban:follow`.
|
|
34
42
|
|
|
35
43
|
## Troubleshooting
|
|
36
44
|
|
|
37
|
-
- **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If
|
|
45
|
+
- **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If it runs elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.
|