claude-code-kanban 7.6.1 → 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/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,5 @@
1
+ {
2
+ "name": "cck-kanbot",
3
+ "version": "0.1.0",
4
+ "description": "The board's own assistant: one agent, loaded with --plugin-dir by the kanbot chat. Moves into claude-code-kanban once it settles."
5
+ }
@@ -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 };
@@ -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, so plain quoting is enough to
146
- // keep a space from splitting an argument and nothing can break out of the quotes.
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}"` : `'${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
- const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
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
- return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
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
- return live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= since - PICK_START_SLACK_MS);
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: 403, error: 'folder is not a known project or a folder picked in this run' };
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 };