claude-code-kanban 7.6.1 → 7.8.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 +391 -70
- package/hub-app.json +3 -3
- package/install.js +11 -9
- 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/live-sessions.js +1 -1
- 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-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +86 -20
- package/lib/user-groups.js +384 -0
- package/package.json +3 -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 +1647 -464
- package/public/group-tree.js +21 -0
- package/public/index.html +53 -28
- package/public/style.css +820 -106
- package/public/sw.js +1 -1
- package/public/terminal-frame.js +12 -0
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +333 -148
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
package/hub-app.json
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
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
|
-
"
|
|
11
|
-
"
|
|
10
|
+
"marketplace": "plugin",
|
|
11
|
+
"name": "claude-code-kanban",
|
|
12
12
|
"install": "claude-code-kanban --install --yes"
|
|
13
13
|
},
|
|
14
14
|
"provides": {
|
package/install.js
CHANGED
|
@@ -163,15 +163,7 @@ async function runInstall({ yes = false } = {}) {
|
|
|
163
163
|
// on stdout — so a re-copied plugin keeps running from the old cached version
|
|
164
164
|
// until `update` pulls the new one in. That is how a config dir ends up serving
|
|
165
165
|
// a plugin older than the board it talks to.
|
|
166
|
-
if (alreadyInstalled)
|
|
167
|
-
const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
|
|
168
|
-
if (upgrade.ok) {
|
|
169
|
-
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
170
|
-
console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
|
|
171
|
-
} else {
|
|
172
|
-
console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
166
|
+
if (alreadyInstalled) updatePlugin(PLUGIN_ID, 'Plugin');
|
|
175
167
|
} else {
|
|
176
168
|
console.log(` ${dim('Skipped')}`);
|
|
177
169
|
}
|
|
@@ -181,6 +173,16 @@ async function runInstall({ yes = false } = {}) {
|
|
|
181
173
|
printSummary(failed);
|
|
182
174
|
}
|
|
183
175
|
|
|
176
|
+
function updatePlugin(id, label) {
|
|
177
|
+
const upgrade = runCLI(`claude plugin update ${id}`, ['already at the latest']);
|
|
178
|
+
if (upgrade.ok) {
|
|
179
|
+
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
180
|
+
console.log(` ${green('✓')} ${label}: ${line ? line.replace(/^[^A-Za-z]*/, '') : 'already at the latest version'}`);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(` ${yellow('⚠')} ${label} update failed: ${upgrade.error}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
184
186
|
function printSummary(failed = false) {
|
|
185
187
|
if (failed) {
|
|
186
188
|
console.log(`\n ${red('Setup incomplete — see the errors above.')}\n`);
|
|
@@ -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 };
|
package/lib/live-sessions.js
CHANGED
|
@@ -16,7 +16,7 @@ function readLiveSessions(sessionsDir) {
|
|
|
16
16
|
try {
|
|
17
17
|
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
|
|
18
18
|
if (s?.sessionId && s.kind === 'interactive') {
|
|
19
|
-
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, name: s.name || null });
|
|
19
|
+
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, statusUpdatedAt: s.statusUpdatedAt || null, name: s.name || null });
|
|
20
20
|
}
|
|
21
21
|
} catch { /* skip invalid */ }
|
|
22
22
|
}
|
|
@@ -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-client.js
CHANGED
|
@@ -42,6 +42,7 @@ function createTerminalClient(o) {
|
|
|
42
42
|
let hostReason = null;
|
|
43
43
|
let ids = [];
|
|
44
44
|
let terminalIds = [];
|
|
45
|
+
let awaitingTrust = [];
|
|
45
46
|
let serverUrl = null;
|
|
46
47
|
let restoreAsked = false;
|
|
47
48
|
let stopped = false;
|
|
@@ -78,6 +79,7 @@ function createTerminalClient(o) {
|
|
|
78
79
|
hostReason = msg.reason;
|
|
79
80
|
ids = msg.ids;
|
|
80
81
|
terminalIds = msg.terminalIds || [];
|
|
82
|
+
awaitingTrust = msg.awaitingTrust || [];
|
|
81
83
|
settle(hostReason);
|
|
82
84
|
o.onChange?.();
|
|
83
85
|
} else if (msg.t === 'exit') o.onExit?.(msg.id);
|
|
@@ -121,6 +123,7 @@ function createTerminalClient(o) {
|
|
|
121
123
|
const lost = ids;
|
|
122
124
|
ids = [];
|
|
123
125
|
terminalIds = [];
|
|
126
|
+
awaitingTrust = [];
|
|
124
127
|
for (const id of lost) o.onExit?.(id);
|
|
125
128
|
o.onChange?.();
|
|
126
129
|
const now = Date.now();
|
|
@@ -228,6 +231,7 @@ function createTerminalClient(o) {
|
|
|
228
231
|
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
|
|
229
232
|
setServerUrl, sessions, stats, shutdown, started,
|
|
230
233
|
ids: () => [...ids],
|
|
234
|
+
awaitingTrust: () => [...awaitingTrust],
|
|
231
235
|
};
|
|
232
236
|
}
|
|
233
237
|
|
package/lib/terminal-host.js
CHANGED
|
@@ -28,7 +28,13 @@ function ask(op, arg) {
|
|
|
28
28
|
// The service reports every attach and detach too; cck needs only a change of ids or reason.
|
|
29
29
|
function pushState() {
|
|
30
30
|
const live = service.list();
|
|
31
|
-
const state = {
|
|
31
|
+
const state = {
|
|
32
|
+
t: 'state',
|
|
33
|
+
reason: service.unavailableReason(),
|
|
34
|
+
ids: live.map((s) => s.id),
|
|
35
|
+
terminalIds: live.map((s) => s.terminalId),
|
|
36
|
+
awaitingTrust: live.filter((s) => s.awaitingTrust).map((s) => s.id),
|
|
37
|
+
};
|
|
32
38
|
const key = JSON.stringify(state);
|
|
33
39
|
if (key === lastState) return;
|
|
34
40
|
lastState = key;
|