claude-code-kanban 7.6.0 → 7.7.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 +2 -1
- package/cli.js +412 -62
- package/hub-app.json +1 -1
- package/kanbot/.claude-plugin/plugin.json +5 -0
- package/kanbot/agents/kanbot.md +34 -0
- package/lib/http-error.js +2 -1
- package/lib/kanbot.js +86 -0
- package/lib/owner-routing.js +53 -0
- package/lib/preview-action.js +55 -0
- package/lib/session-events.js +5 -0
- package/lib/session-pins.js +99 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/terminal.js +27 -13
- package/lib/user-groups.js +364 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +4 -2
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +5 -1
- package/public/app.js +1211 -294
- package/public/index.html +19 -15
- package/public/style.css +727 -125
- package/public/terminal-frame.js +12 -0
- package/server.js +273 -117
package/hub-app.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
-
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
9
|
"plugin": {
|
|
10
10
|
"id": "claude-code-kanban@claude-code-kanban",
|
|
11
11
|
"path": "plugin/plugins/claude-code-kanban",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kanbot
|
|
3
|
+
description: Kanbot, the assistant built into the claude-code-kanban board. Answers questions, looks things up, drives the board through the claude-code-kanban CLI and hands project work to other sessions.
|
|
4
|
+
color: orange
|
|
5
|
+
memory: project
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are Kanbot, a general assistant built into the claude-code-kanban board (cck). The user talks to you in a popover on the board, often about 55 columns wide: answer in a few short lines or a short list, in plain words.
|
|
9
|
+
|
|
10
|
+
You help with anything: questions, research, reading code and transcripts, and running the board. You are the user's front desk, not a worker on their projects.
|
|
11
|
+
|
|
12
|
+
## Delegate the work
|
|
13
|
+
|
|
14
|
+
Work that changes a project (code edits, fixes, features, refactors, long builds or test runs) belongs in its own session, where the user can watch and steer it. Do not do it yourself. Instead:
|
|
15
|
+
|
|
16
|
+
- Suggest a session for it: name the project folder and write the task as a short, self-contained prompt.
|
|
17
|
+
- When the user agrees, start it with the dispatch skill (`/claude-code-kanban:dispatch`) or `claude-code-kanban dispatch start`, or give the user the command to run.
|
|
18
|
+
- For work already running in a session, point the user to that session or send the task there, not to you.
|
|
19
|
+
|
|
20
|
+
Small things that change no project you may do yourself: answer, explain, search, read files, sketch a plan, write a scratch note.
|
|
21
|
+
|
|
22
|
+
## Which session
|
|
23
|
+
|
|
24
|
+
You cannot see which session the user has open on the board. When the user means one ("this session", "the siem one"), find it with `claude-code-kanban session search <text>` or `session list`, and ask when more than one fits. Keep using that session until the user names another.
|
|
25
|
+
|
|
26
|
+
`session list` and `group list` show what the sidebar's Active view shows; add `--all` for every session. `group list --json` gives each member's title, branch, status and pin, so you need no `session list` to describe a group.
|
|
27
|
+
|
|
28
|
+
Your own session is hidden from the board, so the CLI's default session is yours, not the user's. Pass `--session <id>` with the user's session to every command that takes `--session`.
|
|
29
|
+
|
|
30
|
+
## Where to look
|
|
31
|
+
|
|
32
|
+
- **A session**: `session view <id>` prints its project and transcript path. Read the transcript and task files.
|
|
33
|
+
- **cck itself** (keys, themes, panes, settings, the terminal, the hub): read the docs at https://nikiforovall.blog/claude-code-kanban/ with WebFetch. Start from `reference/keyboard-shortcuts/`, `reference/configuration/` or the `guides/` page for the feature.
|
|
34
|
+
- **The board CLI**: its top-level help and this board's URL are below. Read `claude-code-kanban help <command> <subcommand>` for the flags before you run a command.
|
package/lib/http-error.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// An error a route answers with `status`; `code` is the machine-readable reason, where one is set.
|
|
2
|
+
// `expose` lets the API error handler send the message for a 5xx too, because it was written for the user.
|
|
2
3
|
function httpError(status, message, code) {
|
|
3
|
-
return Object.assign(new Error(message), { status, code });
|
|
4
|
+
return Object.assign(new Error(message), { status, code, expose: true });
|
|
4
5
|
}
|
|
5
6
|
|
|
6
7
|
module.exports = { httpError };
|
package/lib/kanbot.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { encodeProjectDirName } = require('./claude-dir');
|
|
6
|
+
const { MODELS } = require('./terminal');
|
|
7
|
+
const { topHelp } = require('../cli');
|
|
8
|
+
|
|
9
|
+
const PLUGIN_DIR = path.join(__dirname, '..', 'kanbot');
|
|
10
|
+
const AGENT = 'cck-kanbot:kanbot';
|
|
11
|
+
const TRANSCRIPT_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
12
|
+
|
|
13
|
+
function kanbotOn(cfg) {
|
|
14
|
+
return cfg?.kanbot?.enabled !== false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function kanbotModel(cfg) {
|
|
18
|
+
const model = cfg?.kanbot?.model;
|
|
19
|
+
return MODELS.has(model) ? model : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeHelpPrompt(cwd, boardUrl) {
|
|
23
|
+
const file = path.join(cwd, 'cli-help.md');
|
|
24
|
+
const board = boardUrl ? `This board runs at ${boardUrl}; \`$CCK_URL\` holds the same URL, so the CLI talks to it.\n\n` : '';
|
|
25
|
+
fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n`);
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function kanbotArgs(cwd, claudeDir, boardUrl) {
|
|
30
|
+
return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl), '--add-dir', claudeDir];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createKanbot({ cckDir, projectsDir }) {
|
|
34
|
+
const cwd = path.join(cckDir, 'kanbot');
|
|
35
|
+
const projectDir = path.join(projectsDir, encodeProjectDirName(cwd));
|
|
36
|
+
const ownIds = new Set();
|
|
37
|
+
let sessionId = null;
|
|
38
|
+
|
|
39
|
+
function transcriptIds() {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readdirSync(projectDir).map((f) => TRANSCRIPT_RE.exec(f)?.[1]).filter(Boolean);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The newest transcript is the chat to resume: /clear starts a new one in the same folder.
|
|
48
|
+
function newestTranscript() {
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const id of transcriptIds()) {
|
|
51
|
+
const mtime = fs.statSync(path.join(projectDir, `${id}.jsonl`), { throwIfNoEntry: false })?.mtimeMs;
|
|
52
|
+
if (mtime && (!best || mtime > best.mtime)) best = { id, mtime };
|
|
53
|
+
}
|
|
54
|
+
return best?.id || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const id of transcriptIds()) ownIds.add(id);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
cwd,
|
|
61
|
+
ptyId: null,
|
|
62
|
+
isOwnPath: (p) => p === projectDir || p.startsWith(projectDir + path.sep),
|
|
63
|
+
isOwnProjectDirName: (name) => name === path.basename(projectDir),
|
|
64
|
+
isOwnSession: (id) => ownIds.has(id),
|
|
65
|
+
// A fresh start writes hook files under its id before claude writes a transcript.
|
|
66
|
+
own: (id) => ownIds.add(id),
|
|
67
|
+
onTranscript(filePath, event) {
|
|
68
|
+
const id = TRANSCRIPT_RE.exec(path.basename(filePath))?.[1];
|
|
69
|
+
if (event === 'unlink' || !id) return;
|
|
70
|
+
sessionId = id;
|
|
71
|
+
ownIds.add(id);
|
|
72
|
+
},
|
|
73
|
+
startSpec({ model = null, boardUrl = null } = {}) {
|
|
74
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
75
|
+
sessionId = newestTranscript();
|
|
76
|
+
const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl);
|
|
77
|
+
// Not restored on a cck restart: a restore would bring it back without the agent args.
|
|
78
|
+
return { cwd, name: 'kanbot', model, extraArgs, restore: false, ...(sessionId && { resume: sessionId }) };
|
|
79
|
+
},
|
|
80
|
+
get sessionId() {
|
|
81
|
+
return sessionId;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { kanbotOn, kanbotModel, createKanbot, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `dispatched` is the store from lib/retention.js; `listToSessions[listId]` is `{[sessionId]: …}`
|
|
2
|
+
// from the plugin's task maps; `metadata` is a getter for session metadata.
|
|
3
|
+
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
const linkKey = (list, owner) => `${list}\n${owner}`;
|
|
6
|
+
|
|
7
|
+
// A list's candidates are the sessions the list's session dispatched (a session's own list)
|
|
8
|
+
// and the sessions mapped to the list (a shared list). `owner` matches the live `agentName`,
|
|
9
|
+
// which follows /rename the same way SendMessage does. A name two candidates share gets no link.
|
|
10
|
+
function ownerLinks(listIds, { dispatched, listToSessions, metadata }) {
|
|
11
|
+
const candidates = new Map();
|
|
12
|
+
const add = (list, id) => {
|
|
13
|
+
if (!candidates.has(list)) candidates.set(list, new Set());
|
|
14
|
+
candidates.get(list).add(id);
|
|
15
|
+
};
|
|
16
|
+
for (const [id, e] of dispatched.entries()) if (listIds.has(e?.parent)) add(e.parent, id);
|
|
17
|
+
for (const list of listIds) for (const id of Object.keys(listToSessions[list] || {})) add(list, id);
|
|
18
|
+
|
|
19
|
+
const links = new Map();
|
|
20
|
+
if (candidates.size) {
|
|
21
|
+
const meta = metadata();
|
|
22
|
+
for (const [list, ids] of candidates) {
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
const name = meta[id]?.agentName;
|
|
25
|
+
if (!name) continue;
|
|
26
|
+
const key = linkKey(list, name);
|
|
27
|
+
links.set(key, links.has(key) ? null : id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return (list, owner) => links.get(linkKey(list, owner)) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Who gets a board move on a shared list: the owner, else the one session that dispatched the
|
|
35
|
+
// list's sessions, else no one, because a line to every worker makes them race for the task.
|
|
36
|
+
// Null when the rule does not apply: a session's own list goes to that session, the orchestrator,
|
|
37
|
+
// even for a child's card, and a list with no task map (a team board) keeps its old routing.
|
|
38
|
+
function moveRecipients(listId, owner, inputs) {
|
|
39
|
+
const mapped = !UUID_RE.test(listId) && inputs.listToSessions[listId];
|
|
40
|
+
if (!mapped) return null;
|
|
41
|
+
const id = owner && ownerLinks(new Set([listId]), inputs)(listId, owner);
|
|
42
|
+
if (id) return [id];
|
|
43
|
+
const onList = Object.keys(mapped);
|
|
44
|
+
const on = new Set(onList);
|
|
45
|
+
const parentOf = (s) => inputs.dispatched.get(s)?.parent || null;
|
|
46
|
+
// A session on the list that dispatched others on it is the orchestrator; its own parent is
|
|
47
|
+
// outside the swarm and does not count.
|
|
48
|
+
const orchestrators = new Set(onList.map(parentOf).filter((p) => on.has(p)));
|
|
49
|
+
const parents = new Set(onList.filter((s) => !orchestrators.has(s)).map(parentOf).filter(Boolean));
|
|
50
|
+
return parents.size === 1 ? [...parents] : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { ownerLinks, moveRecipients };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { oneLine } = require('./one-line');
|
|
2
|
+
const { httpError } = require('./http-error');
|
|
3
|
+
|
|
4
|
+
const ACTION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
5
|
+
const KIND_RE = /^[a-z]{1,20}$/;
|
|
6
|
+
const MAX_FIELDS = 50;
|
|
7
|
+
const MAX_VALUES = 50;
|
|
8
|
+
const MAX_CHARS = 4000;
|
|
9
|
+
|
|
10
|
+
// The `source` of a review or an action: the view it came from. The caller resolves `path`.
|
|
11
|
+
function parseReviewSource(source) {
|
|
12
|
+
if (!KIND_RE.test(source?.kind || '')) throw httpError(400, 'source.kind is required');
|
|
13
|
+
// The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
|
|
14
|
+
const label = oneLine(source.label, 200);
|
|
15
|
+
if (!label) throw httpError(400, 'source.label is required');
|
|
16
|
+
return {
|
|
17
|
+
kind: source.kind,
|
|
18
|
+
label,
|
|
19
|
+
path: source.kind === 'file' ? source.path : null,
|
|
20
|
+
locate: oneLine(source.locate, 600) || null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The fields come from a form inside a sandboxed card, so the card's author picks the names and
|
|
25
|
+
// values: they are capped here and printed as JSON, never as markdown.
|
|
26
|
+
function parseActionBody(body) {
|
|
27
|
+
const { source, action, label, data } = body || {};
|
|
28
|
+
const src = parseReviewSource(source);
|
|
29
|
+
if (!ACTION_RE.test(action || '')) throw httpError(400, 'action must match [A-Za-z0-9._-]{1,64}');
|
|
30
|
+
const fields = {};
|
|
31
|
+
const entries = Object.entries(data && typeof data === 'object' && !Array.isArray(data) ? data : {});
|
|
32
|
+
if (entries.length > MAX_FIELDS) throw httpError(400, `data holds at most ${MAX_FIELDS} fields`);
|
|
33
|
+
for (const [k, v] of entries) {
|
|
34
|
+
const list = Array.isArray(v) ? v : [v];
|
|
35
|
+
if (list.length > MAX_VALUES || list.some((x) => typeof x !== 'string')) {
|
|
36
|
+
throw httpError(400, 'each field is a string or a list of strings');
|
|
37
|
+
}
|
|
38
|
+
const vals = list.map((x) => x.slice(0, MAX_CHARS));
|
|
39
|
+
fields[k.slice(0, 200)] = Array.isArray(v) ? vals : vals[0];
|
|
40
|
+
}
|
|
41
|
+
return { src, action, label: oneLine(label, 200) || action, fields };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatActionMarkdown({ src, action, label, fields }) {
|
|
45
|
+
const parts = [
|
|
46
|
+
`# Action "${action}" on ${src.path || src.label}`,
|
|
47
|
+
'',
|
|
48
|
+
`The user pressed "${label}" on the page in the board. Treat it as the user's answer.`,
|
|
49
|
+
];
|
|
50
|
+
if (src.locate) parts.push('', src.locate);
|
|
51
|
+
if (Object.keys(fields).length) parts.push('', '```json', JSON.stringify(fields, null, 2), '```');
|
|
52
|
+
return `${parts.join('\n')}\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { parseReviewSource, parseActionBody, formatActionMarkdown };
|
package/lib/session-events.js
CHANGED
|
@@ -68,6 +68,10 @@ function formatReviewSubmitted(count, label, reviewFile) {
|
|
|
68
68
|
return `${EVENT_PREFIX} Address ${comments} on ${label}: ${reviewFile}`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function formatActionSubmitted(action, label, file) {
|
|
72
|
+
return `${EVENT_PREFIX} User submitted "${action}" on ${label}: ${file}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
// Names this board run in every reply, so a mod drops `got` and `ack` counted against a run
|
|
72
76
|
// that has since restarted.
|
|
73
77
|
const BOARD_RUN = crypto.randomBytes(6).toString('hex');
|
|
@@ -260,6 +264,7 @@ module.exports = {
|
|
|
260
264
|
formatTaskMoved,
|
|
261
265
|
boardEventsOn,
|
|
262
266
|
formatReviewSubmitted,
|
|
267
|
+
formatActionSubmitted,
|
|
263
268
|
hasDoorbell,
|
|
264
269
|
configureSessionEvents,
|
|
265
270
|
enqueueSessionEvent,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Session pins: which sessions the sidebar pins, and which of those are sticky. The board, the CLI
|
|
2
|
+
// and every board on the same config dir share them through `.cck/pins.json`, a flat
|
|
3
|
+
// {sessionId: "pinned"|"sticky"} map. Each write changes only the sessions it names, so the last
|
|
4
|
+
// write for a session wins.
|
|
5
|
+
|
|
6
|
+
const { httpError } = require('./http-error');
|
|
7
|
+
|
|
8
|
+
const MAX_PINS = 1000;
|
|
9
|
+
const MAX_REF = 4096;
|
|
10
|
+
const STATES = new Set(['pinned', 'sticky']);
|
|
11
|
+
|
|
12
|
+
const fail = (status, message) => {
|
|
13
|
+
throw httpError(status, message);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const isRef = (s) => typeof s === 'string' && s.length > 0 && s.length <= MAX_REF;
|
|
17
|
+
|
|
18
|
+
function readPins(obj) {
|
|
19
|
+
const out = {};
|
|
20
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return out;
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (const [id, state] of Object.entries(obj)) {
|
|
23
|
+
if (n >= MAX_PINS) break;
|
|
24
|
+
if (isRef(id) && STATES.has(state)) {
|
|
25
|
+
out[id] = state;
|
|
26
|
+
n++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} o
|
|
34
|
+
* @param {() => object|null|undefined} o.load `undefined` means keep what is in memory: the file has
|
|
35
|
+
* not changed since the last load, or cannot be read
|
|
36
|
+
* @param {(data: object) => void} o.save throws when the pins must not or cannot be written
|
|
37
|
+
*/
|
|
38
|
+
function createSessionPinStore({ load, save }) {
|
|
39
|
+
let pins = {};
|
|
40
|
+
|
|
41
|
+
// Another board on the same config dir writes the same file. Returns the sessions whose state
|
|
42
|
+
// changed, as [{id, state}] with state 'none' for a removed pin.
|
|
43
|
+
function reload() {
|
|
44
|
+
const saved = load();
|
|
45
|
+
if (saved === undefined) return [];
|
|
46
|
+
const before = pins;
|
|
47
|
+
pins = readPins(saved);
|
|
48
|
+
return diff(before, pins);
|
|
49
|
+
}
|
|
50
|
+
reload();
|
|
51
|
+
|
|
52
|
+
const state = () => ({ pins });
|
|
53
|
+
|
|
54
|
+
function commit(next) {
|
|
55
|
+
save(next);
|
|
56
|
+
const changed = diff(pins, next);
|
|
57
|
+
pins = next;
|
|
58
|
+
return changed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function set(ids, s) {
|
|
62
|
+
if (!Array.isArray(ids) || !ids.length || !ids.every(isRef)) fail(400, 'ids is required');
|
|
63
|
+
if (s !== 'none' && !STATES.has(s)) fail(400, 'state must be none|pinned|sticky');
|
|
64
|
+
const next = { ...pins };
|
|
65
|
+
for (const id of ids) {
|
|
66
|
+
if (s === 'none') delete next[id];
|
|
67
|
+
else next[id] = s;
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(next).length > MAX_PINS) fail(422, `at most ${MAX_PINS} pins`);
|
|
70
|
+
return commit(next);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A browser's lists from before the server kept pins. Each origin sends its own once, so every
|
|
74
|
+
// import is merged; a session the server already holds keeps its state.
|
|
75
|
+
function importLocal({ pinned, sticky } = {}) {
|
|
76
|
+
const next = { ...pins };
|
|
77
|
+
const add = (list, s) => {
|
|
78
|
+
for (const id of Array.isArray(list) ? list : []) {
|
|
79
|
+
if (Object.keys(next).length >= MAX_PINS) return;
|
|
80
|
+
if (isRef(id) && !(id in next)) next[id] = s;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
add(sticky, 'sticky');
|
|
84
|
+
add(pinned, 'pinned');
|
|
85
|
+
return commit(next);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { state, reload, set, importLocal };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function diff(before, after) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const id of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
94
|
+
if (before[id] !== after[id]) out.push({ id, state: after[id] || 'none' });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { createSessionPinStore };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const { readFileSync, statSync } = require('node:fs');
|
|
3
|
+
const { httpError } = require('./http-error');
|
|
4
|
+
|
|
5
|
+
// A JSON file that the board keeps in memory and other boards on the same config dir also write.
|
|
6
|
+
// `load` answers undefined while the file's mtime and size are the ones last read or written, and
|
|
7
|
+
// null when there is no file. A file that cannot be read or parsed (a hand edit, a cut-off write,
|
|
8
|
+
// EBUSY while another board renames over it) also answers undefined, so memory keeps the last good
|
|
9
|
+
// state, and the next load tries again. While it cannot be read, `save` refuses with a 503, because
|
|
10
|
+
// the write would replace the user's file. A failed write is a 503 too.
|
|
11
|
+
function stampedJsonFile(file, writeJsonAtomic) {
|
|
12
|
+
const name = path.basename(file);
|
|
13
|
+
const stampOf = () => {
|
|
14
|
+
try {
|
|
15
|
+
const s = statSync(file);
|
|
16
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
let stamp;
|
|
22
|
+
let readError = null;
|
|
23
|
+
return {
|
|
24
|
+
load: () => {
|
|
25
|
+
const next = stampOf();
|
|
26
|
+
if (next === stamp && !readError) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
const data = next === null ? null : JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
readError = null;
|
|
30
|
+
stamp = next;
|
|
31
|
+
return data;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
readError = e;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
save: (data) => {
|
|
38
|
+
if (readError) throw httpError(503, `${name} cannot be read (${readError.code || readError.message}); fix or delete it`);
|
|
39
|
+
try {
|
|
40
|
+
writeJsonAtomic(file, data);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw httpError(503, `${name} not saved (${e.code || e.message})`);
|
|
43
|
+
}
|
|
44
|
+
stamp = stampOf();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { stampedJsonFile };
|
package/lib/terminal.js
CHANGED
|
@@ -37,7 +37,7 @@ const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
|
37
37
|
// Extra claude args reach the command line inside plain quotes (quoteArg), so a character
|
|
38
38
|
// that ends or expands those quotes in any of the shells is refused.
|
|
39
39
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
|
|
40
|
-
const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f
|
|
40
|
+
const EXTRA_ARG_RE = /^[^\x00-\x1f\x7f"%]*$/;
|
|
41
41
|
const MAX_EXTRA_ARGS = 64;
|
|
42
42
|
const MAX_EXTRA_ARG = 4096;
|
|
43
43
|
// cck sets these itself, or they would start something other than a new session.
|
|
@@ -142,11 +142,11 @@ function resolveShell(value, which, platform = process.platform, env = process.e
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
|
|
145
|
-
// values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE
|
|
146
|
-
//
|
|
145
|
+
// values that passed NAME_RE / WORKTREE_RE / EXTRA_ARG_RE. Those hold no " or %, so cmd's
|
|
146
|
+
// double quotes hold them, and quotePrompt escapes the ' a path such as O'Brien can carry.
|
|
147
147
|
function quoteArg(arg, family) {
|
|
148
148
|
if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
|
|
149
|
-
return family === 'cmd' ? `"${arg}"` :
|
|
149
|
+
return family === 'cmd' ? `"${arg}"` : quotePrompt(arg, family);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Free text, unlike quoteArg's input. Inside single quotes pwsh only reads a quote mark (it
|
|
@@ -182,7 +182,9 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
182
182
|
if (mode === 'new') {
|
|
183
183
|
// claude takes --session-id with --resume only together with --fork-session.
|
|
184
184
|
const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
|
|
185
|
-
|
|
185
|
+
// A plain resume keeps the transcript's id, so the PTY's own id is not passed on.
|
|
186
|
+
const own = spec.resume ? ['--resume', spec.resume] : ['--session-id', id];
|
|
187
|
+
const args = [...fork, ...own, ...(spec.extraArgs || [])];
|
|
186
188
|
if (spec.name) args.push('--name', spec.name);
|
|
187
189
|
if (spec.model) args.push('--model', spec.model);
|
|
188
190
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -196,7 +198,7 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
196
198
|
function extraArgsError(args) {
|
|
197
199
|
if (!Array.isArray(args) || args.length > MAX_EXTRA_ARGS) return `claude args (at most ${MAX_EXTRA_ARGS})`;
|
|
198
200
|
for (const a of args) {
|
|
199
|
-
if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no quotes, % or control characters';
|
|
201
|
+
if (typeof a !== 'string' || a.length > MAX_EXTRA_ARG || !EXTRA_ARG_RE.test(a)) return 'claude arg: no double quotes, % or control characters';
|
|
200
202
|
const flag = a.split('=')[0];
|
|
201
203
|
if (OWNED_FLAGS.has(flag)) return `claude arg: cck sets ${flag}`;
|
|
202
204
|
}
|
|
@@ -207,6 +209,8 @@ function extraArgsError(args) {
|
|
|
207
209
|
function parseNewSpec(msg) {
|
|
208
210
|
const forkOf = msg.forkOf ?? null;
|
|
209
211
|
if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
|
|
212
|
+
const resume = msg.resume ?? null;
|
|
213
|
+
if (resume !== null && (typeof resume !== 'string' || !UUID_RE.test(resume) || forkOf)) return 'session to resume';
|
|
210
214
|
if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
|
|
211
215
|
const name = typeof msg.name === 'string' ? msg.name.trim() : '';
|
|
212
216
|
if (name && !NAME_RE.test(name)) return 'name';
|
|
@@ -227,7 +231,8 @@ function parseNewSpec(msg) {
|
|
|
227
231
|
const bad = extraArgsError(extraArgs);
|
|
228
232
|
if (bad) return bad;
|
|
229
233
|
if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
|
|
230
|
-
|
|
234
|
+
const restore = msg.restore !== false;
|
|
235
|
+
return { cwd: forkOf ? null : msg.cwd, forkOf, resume, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit, restore };
|
|
231
236
|
}
|
|
232
237
|
|
|
233
238
|
function samePath(a, b) {
|
|
@@ -258,7 +263,11 @@ function screenText(term) {
|
|
|
258
263
|
// show tool posts to.
|
|
259
264
|
function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl, terminalId }) {
|
|
260
265
|
const env = { ...process.env };
|
|
266
|
+
const inHub = !!env.CLAUDE_HUB;
|
|
261
267
|
for (const k of STRIP_ENV) delete env[k];
|
|
268
|
+
// Claude Code reports TERM_PROGRAM as the metrics attribute terminal_type.
|
|
269
|
+
env.TERM_PROGRAM = inHub ? 'claude-code-hub' : 'claude-code-kanban';
|
|
270
|
+
delete env.TERM_PROGRAM_VERSION;
|
|
262
271
|
if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
|
|
263
272
|
else env.CLAUDE_CONFIG_DIR = claudeDir;
|
|
264
273
|
if (cckUrl) env.CCK_URL = cckUrl;
|
|
@@ -418,7 +427,7 @@ function createTerminalService(o) {
|
|
|
418
427
|
if (savedTerminalIds[id]) terminalIds[id] = savedTerminalIds[id];
|
|
419
428
|
}
|
|
420
429
|
for (const s of sessions.values()) {
|
|
421
|
-
if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode)) continue;
|
|
430
|
+
if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode) || !s.restore) continue;
|
|
422
431
|
ids.add(s.id);
|
|
423
432
|
if (s.taskList) taskLists[s.id] = s.taskList;
|
|
424
433
|
terminalIds[s.id] = s.terminalId;
|
|
@@ -544,8 +553,10 @@ function createTerminalService(o) {
|
|
|
544
553
|
if (moved) changed();
|
|
545
554
|
}
|
|
546
555
|
|
|
556
|
+
// A resumed transcript keeps its own id, which is not the PTY's.
|
|
547
557
|
function ownEntry(live, s, since) {
|
|
548
|
-
|
|
558
|
+
const sid = s.resumeOf || s.id;
|
|
559
|
+
return live.find((l) => l.pid && l.sessionId === sid && l.startedAt >= since - PICK_START_SLACK_MS);
|
|
549
560
|
}
|
|
550
561
|
|
|
551
562
|
// claude writes its registry entry a moment after it starts, so the pid is polled for.
|
|
@@ -595,7 +606,7 @@ function createTerminalService(o) {
|
|
|
595
606
|
const s = {
|
|
596
607
|
id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
597
608
|
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
598
|
-
forkOf: spec?.forkOf || null,
|
|
609
|
+
forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false,
|
|
599
610
|
};
|
|
600
611
|
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
601
612
|
else if (spec?.prompt) queueText(s, spec.prompt, true);
|
|
@@ -744,6 +755,9 @@ function createTerminalService(o) {
|
|
|
744
755
|
if (mode === 'new') {
|
|
745
756
|
spec = parseNewSpec(msg);
|
|
746
757
|
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
758
|
+
if (spec.resume && o.isLiveElsewhere(spec.resume)) {
|
|
759
|
+
return { code: 4006, status: 409, error: 'the session to resume is running in another terminal' };
|
|
760
|
+
}
|
|
747
761
|
if (spec.edit && spec.prompt && shellFamily(shell) === 'cmd') {
|
|
748
762
|
return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
|
|
749
763
|
}
|
|
@@ -756,7 +770,7 @@ function createTerminalService(o) {
|
|
|
756
770
|
cwd = spec?.forkOf ? forkCwd : msg.cwd;
|
|
757
771
|
if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
|
|
758
772
|
if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
|
|
759
|
-
return { code: 4004, status:
|
|
773
|
+
return { code: 4004, status: 404, error: 'folder does not exist' };
|
|
760
774
|
}
|
|
761
775
|
} else if (mode !== 'shell' && known === null) {
|
|
762
776
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
@@ -803,7 +817,7 @@ function createTerminalService(o) {
|
|
|
803
817
|
function list() {
|
|
804
818
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
805
819
|
id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
806
|
-
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
|
|
820
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt,
|
|
807
821
|
}));
|
|
808
822
|
}
|
|
809
823
|
|
|
@@ -878,4 +892,4 @@ function createTerminalService(o) {
|
|
|
878
892
|
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
879
893
|
}
|
|
880
894
|
|
|
881
|
-
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
895
|
+
module.exports = { MODELS,createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|