claude-code-kanban 7.5.0 → 7.6.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/hub-app.json +5 -0
- package/lib/panes.js +16 -2
- package/lib/parsers.js +27 -2
- package/lib/retention.js +29 -6
- package/lib/session-events.js +154 -42
- package/lib/show.js +385 -0
- package/lib/terminal-client.js +14 -1
- package/lib/terminal-host.js +2 -1
- package/lib/terminal.js +27 -7
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
- package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
- package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
- package/public/app.js +1149 -108
- package/public/index.html +7 -6
- package/public/style.css +770 -9
- package/public/terminal-frame.js +0 -2
- package/public/tree-highlight.js +137 -15
- package/server.js +71 -24
- package/lib/dispatch.js +0 -32
package/lib/show.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The show overlay's server side: posts a terminal's session makes through the plugin's show tool.
|
|
2
|
+
// The card belongs to the terminal and the posts to the session, so the store maps terminal id
|
|
3
|
+
// to its current session and keeps each session's posts in <scratchpad>/.cck/show/.
|
|
4
|
+
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const { httpError } = require('./http-error');
|
|
10
|
+
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const SHOW_PATH = '/api/terminals/:terminalId/show';
|
|
13
|
+
const BODY_LIMIT = '16kb';
|
|
14
|
+
const MAX_LABEL = 200;
|
|
15
|
+
const EXT = { markdown: 'md', html: 'html' };
|
|
16
|
+
const CONTENT_FILE_RE = new RegExp(`^(${UUID_RE.source.slice(1, -1)})\\.(md|html)$`, 'i');
|
|
17
|
+
// An editor can save in two steps, so a refresh waits for the writes to settle.
|
|
18
|
+
const SETTLE_MS = 200;
|
|
19
|
+
|
|
20
|
+
function isLabel(v) {
|
|
21
|
+
return typeof v === 'string' && v.trim() !== '' && v.length <= MAX_LABEL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A string is a reason the body is refused.
|
|
25
|
+
function parsePost(body) {
|
|
26
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
|
|
27
|
+
const { sessionId, title, key, kind = 'markdown', content, file } = body;
|
|
28
|
+
if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
|
|
29
|
+
if (!isLabel(title)) return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
30
|
+
if (key != null && !isLabel(key)) return `key must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
31
|
+
if (!Object.hasOwn(EXT, kind)) return 'kind must be markdown or html';
|
|
32
|
+
if (content != null) return 'content is not accepted: call show with only title and key to get a file to write the card into';
|
|
33
|
+
if (file != null && (typeof file !== 'string' || !path.isAbsolute(file))) return 'file must be an absolute path';
|
|
34
|
+
return { sessionId, title, key: key ?? null, kind, file: file ?? null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeAtomic(file, text) {
|
|
38
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
41
|
+
fs.renameSync(tmp, file);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
fs.rmSync(tmp, { force: true });
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* @param {() => Object<string, string>|null} o.load terminal id → session id, as saved
|
|
51
|
+
* @param {() => void} o.onChange the map changed and should be saved
|
|
52
|
+
* @param {(sessionId: string) => string|null} o.resolveDir the session's scratchpad, null when its transcript is unknown
|
|
53
|
+
* @param {(terminalId: string) => string|null} [o.startedSession] the session a live terminal was started with
|
|
54
|
+
* @param {(e: object) => void} [o.onPosted] a post was made or its file changed on disk, once per terminal showing its session
|
|
55
|
+
* @param {() => number} [o.now]
|
|
56
|
+
*/
|
|
57
|
+
function createShowStore(o) {
|
|
58
|
+
const now = o.now || Date.now;
|
|
59
|
+
const current = new Map();
|
|
60
|
+
const watchers = new Map();
|
|
61
|
+
// Post id → the claim file's mtime as cck last saw it, so a second watch event for one save is not a change.
|
|
62
|
+
const seen = new Map();
|
|
63
|
+
for (const [terminalId, sessionId] of Object.entries(o.load?.() || {})) {
|
|
64
|
+
if (UUID_RE.test(terminalId) && typeof sessionId === 'string' && UUID_RE.test(sessionId)) current.set(terminalId, sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function folder(sessionId) {
|
|
68
|
+
const dir = o.resolveDir(sessionId);
|
|
69
|
+
return dir ? path.join(dir, '.cck', 'show') : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function contentPath(dir, entry) {
|
|
73
|
+
return path.join(dir, `${entry.id}.${EXT[entry.kind]}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function postPath(dir, entry) {
|
|
77
|
+
return entry.file || contentPath(dir, entry);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function locate(terminalId, postId) {
|
|
81
|
+
const sessionId = sessionOf(terminalId);
|
|
82
|
+
const dir = sessionId && UUID_RE.test(postId) ? folder(sessionId) : null;
|
|
83
|
+
const posts = dir ? readPosts(dir) : [];
|
|
84
|
+
return { sessionId, dir, posts, at: posts.findIndex((p) => p.id === postId) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeIndex(dir, sessionId, posts) {
|
|
88
|
+
writeAtomic(path.join(dir, 'index.json'), JSON.stringify({ sessionId, posts }, null, 2));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notify(sessionId, entry, at, count, replaced) {
|
|
92
|
+
if (!o.onPosted) return;
|
|
93
|
+
for (const [terminalId, s] of current) {
|
|
94
|
+
if (s !== sessionId) continue;
|
|
95
|
+
o.onPosted({ terminalId, sessionId, id: entry.id, title: entry.title, key: entry.key, index: at + 1, count, replaced });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function touch(sessionId, dir, postId) {
|
|
100
|
+
const posts = readPosts(dir);
|
|
101
|
+
const at = posts.findIndex((p) => p.id === postId);
|
|
102
|
+
const entry = posts[at];
|
|
103
|
+
if (!entry || entry.file) return;
|
|
104
|
+
let mtime;
|
|
105
|
+
try {
|
|
106
|
+
mtime = fs.statSync(contentPath(dir, entry)).mtimeMs;
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (mtime === seen.get(postId)) return;
|
|
111
|
+
seen.set(postId, mtime);
|
|
112
|
+
entry.updatedAt = new Date(now()).toISOString();
|
|
113
|
+
writeIndex(dir, sessionId, posts);
|
|
114
|
+
notify(sessionId, entry, at, posts.length, true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function watch(sessionId, dir) {
|
|
118
|
+
if (!o.onPosted || watchers.has(sessionId)) return;
|
|
119
|
+
let w;
|
|
120
|
+
try {
|
|
121
|
+
// libuv aborts the process on a change under an 8.3 short path (NIKIFO~1), so watch the long form.
|
|
122
|
+
w = fs.watch(fs.realpathSync.native(dir));
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
w.unref();
|
|
127
|
+
const timers = new Map();
|
|
128
|
+
// A watch left on a deleted folder keeps the process from exiting on Windows.
|
|
129
|
+
w.on('change', (event, name) => {
|
|
130
|
+
if (event === 'rename' && !fs.existsSync(dir)) return unwatch(sessionId);
|
|
131
|
+
const id = CONTENT_FILE_RE.exec(String(name ?? ''))?.[1];
|
|
132
|
+
if (!id) return;
|
|
133
|
+
clearTimeout(timers.get(id));
|
|
134
|
+
const t = setTimeout(() => {
|
|
135
|
+
timers.delete(id);
|
|
136
|
+
try {
|
|
137
|
+
touch(sessionId, dir, id);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error('[show] refresh failed:', e.message);
|
|
140
|
+
}
|
|
141
|
+
}, SETTLE_MS);
|
|
142
|
+
t.unref();
|
|
143
|
+
timers.set(id, t);
|
|
144
|
+
});
|
|
145
|
+
w.on('error', () => unwatch(sessionId));
|
|
146
|
+
watchers.set(sessionId, () => {
|
|
147
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
148
|
+
w.close();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unwatch(sessionId) {
|
|
153
|
+
watchers.get(sessionId)?.();
|
|
154
|
+
watchers.delete(sessionId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function unwatchUnused() {
|
|
158
|
+
const used = new Set(current.values());
|
|
159
|
+
for (const sessionId of watchers.keys()) if (!used.has(sessionId)) unwatch(sessionId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readPosts(dir) {
|
|
163
|
+
try {
|
|
164
|
+
const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
|
|
165
|
+
if (Array.isArray(index.posts)) return index.posts;
|
|
166
|
+
} catch { /* no posts yet */ }
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function removeContent(dir, id) {
|
|
171
|
+
for (const ext of Object.values(EXT)) fs.rmSync(path.join(dir, `${id}.${ext}`), { force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A session resumed in a new terminal gets a new terminal id, so a terminal with no posts yet
|
|
175
|
+
// takes the posts of the session it was started with.
|
|
176
|
+
function sessionOf(terminalId) {
|
|
177
|
+
const known = current.get(terminalId);
|
|
178
|
+
if (known) return known;
|
|
179
|
+
const started = o.startedSession?.(terminalId);
|
|
180
|
+
const dir = started && UUID_RE.test(started) ? folder(started) : null;
|
|
181
|
+
if (!dir || !readPosts(dir).length) return null;
|
|
182
|
+
bind(terminalId, started);
|
|
183
|
+
return started;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function bind(terminalId, sessionId) {
|
|
187
|
+
if (current.get(terminalId) === sessionId) return;
|
|
188
|
+
current.set(terminalId, sessionId);
|
|
189
|
+
unwatchUnused();
|
|
190
|
+
o.onChange();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// `kind` of a file post is the one readPreviewFile gave when it was posted. A post without a file
|
|
194
|
+
// is a claim: cck names the file and the session writes it. cck does not create it, because
|
|
195
|
+
// Claude Code's Write refuses to overwrite a file the session has not read.
|
|
196
|
+
function post(terminalId, { sessionId, title, key, kind, file }) {
|
|
197
|
+
const dir = folder(sessionId);
|
|
198
|
+
if (!dir) throw httpError(409, 'no transcript found for that session');
|
|
199
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
200
|
+
const posts = readPosts(dir);
|
|
201
|
+
let at = key == null ? -1 : posts.findIndex((p) => p.key === key);
|
|
202
|
+
const old = at >= 0 ? posts[at] : null;
|
|
203
|
+
const id = old ? old.id : crypto.randomUUID();
|
|
204
|
+
const target = file == null ? contentPath(dir, { id, kind }) : null;
|
|
205
|
+
if (target && old && !old.file) {
|
|
206
|
+
const from = contentPath(dir, old);
|
|
207
|
+
if (from !== target && fs.existsSync(from)) fs.renameSync(from, target);
|
|
208
|
+
} else if (old) {
|
|
209
|
+
removeContent(dir, id);
|
|
210
|
+
seen.delete(id);
|
|
211
|
+
}
|
|
212
|
+
if (!old) at = posts.length;
|
|
213
|
+
const entry = { id, title, key, kind, file, updatedAt: new Date(now()).toISOString() };
|
|
214
|
+
posts[at] = entry;
|
|
215
|
+
writeIndex(dir, sessionId, posts);
|
|
216
|
+
bind(terminalId, sessionId);
|
|
217
|
+
watch(sessionId, dir);
|
|
218
|
+
notify(sessionId, entry, at, posts.length, !!old);
|
|
219
|
+
return { id, title, key, index: at + 1, count: posts.length, replaced: !!old, path: target };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function list(terminalId) {
|
|
223
|
+
const sessionId = sessionOf(terminalId);
|
|
224
|
+
if (!sessionId) return { sessionId: null, posts: [] };
|
|
225
|
+
const dir = folder(sessionId);
|
|
226
|
+
const posts = dir ? readPosts(dir) : [];
|
|
227
|
+
if (posts.length) watch(sessionId, dir);
|
|
228
|
+
return { sessionId, posts };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The post's index entry and its stored content; `waiting` when a claim file is not written yet.
|
|
232
|
+
function get(terminalId, postId) {
|
|
233
|
+
const { dir, posts, at } = locate(terminalId, postId);
|
|
234
|
+
if (at < 0) return null;
|
|
235
|
+
const entry = posts[at];
|
|
236
|
+
const file = postPath(dir, entry);
|
|
237
|
+
if (entry.file) return { entry, content: null, path: file };
|
|
238
|
+
try {
|
|
239
|
+
return { entry, content: fs.readFileSync(file, 'utf8'), path: file };
|
|
240
|
+
} catch {
|
|
241
|
+
return { entry, content: null, path: file, waiting: true };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Returns the session and the paths of the posts it had.
|
|
246
|
+
function clear(terminalId) {
|
|
247
|
+
const sessionId = sessionOf(terminalId);
|
|
248
|
+
const dir = sessionId && folder(sessionId);
|
|
249
|
+
const paths = [];
|
|
250
|
+
if (dir) {
|
|
251
|
+
unwatch(sessionId);
|
|
252
|
+
for (const p of readPosts(dir)) {
|
|
253
|
+
seen.delete(p.id);
|
|
254
|
+
paths.push(postPath(dir, p));
|
|
255
|
+
}
|
|
256
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
return { sessionId, paths };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A file post's file belongs to the session, so only cck's own copy is deleted.
|
|
262
|
+
function remove(terminalId, postId) {
|
|
263
|
+
const { sessionId, dir, posts, at } = locate(terminalId, postId);
|
|
264
|
+
if (at < 0) return null;
|
|
265
|
+
const [entry] = posts.splice(at, 1);
|
|
266
|
+
writeIndex(dir, sessionId, posts);
|
|
267
|
+
removeContent(dir, postId);
|
|
268
|
+
seen.delete(postId);
|
|
269
|
+
return { sessionId, count: posts.length, path: postPath(dir, entry) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Drops the terminals that `keep` no longer knows, and gives the map to save.
|
|
273
|
+
function prune(keep) {
|
|
274
|
+
for (const terminalId of current.keys()) if (!keep(terminalId)) current.delete(terminalId);
|
|
275
|
+
unwatchUnused();
|
|
276
|
+
return Object.fromEntries(current);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { sessionOf, post, list, get, clear, remove, prune };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Mounted before the global JSON parser: the parser that runs first owns the body.
|
|
283
|
+
function showBodyParser() {
|
|
284
|
+
return express.json({ limit: BODY_LIMIT });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {import('express').Express} app
|
|
289
|
+
* @param {object} o
|
|
290
|
+
* @param {ReturnType<typeof createShowStore>} o.store
|
|
291
|
+
* @param {(token: string|undefined) => boolean} o.authorized the terminal token
|
|
292
|
+
* @param {(terminalId: string) => boolean} o.hasTerminal a live terminal has this id
|
|
293
|
+
* @param {(absPath: string) => Promise<{content: string|null, kind: string|null}>} o.readPreviewFile
|
|
294
|
+
* @param {(event: object) => void} o.broadcast
|
|
295
|
+
* @param {(sessionId: string, paths: string[]) => void} [o.onRemoved] posts left the card; their files' panes go too
|
|
296
|
+
*/
|
|
297
|
+
function mountShowRoutes(app, o) {
|
|
298
|
+
const { store } = o;
|
|
299
|
+
|
|
300
|
+
function guard(req, res) {
|
|
301
|
+
if (!o.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
302
|
+
if (!o.hasTerminal(req.params.terminalId)) return res.status(404).json({ error: 'no live terminal with that id' });
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readFilePost(file) {
|
|
307
|
+
let read;
|
|
308
|
+
try {
|
|
309
|
+
read = await o.readPreviewFile(file);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
if (e.status === 404) throw httpError(404, `file not found: ${file}`);
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
if (!read.kind) throw httpError(400, 'not a markdown, HTML, text or image file');
|
|
315
|
+
// The previewer shows SVG as source; the card draws it in its sandboxed frame. The prolog
|
|
316
|
+
// would read as a whole document there.
|
|
317
|
+
if (read.kind === 'text' && /\.svg$/i.test(file)) {
|
|
318
|
+
return { kind: 'html', content: read.content.replace(/^(?:\s*(?:<\?xml[\s\S]*?\?>|<!doctype[^>]*>))*/i, '') };
|
|
319
|
+
}
|
|
320
|
+
return read;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const route = (fn) => (req, res) => fn(req, res).catch((e) => {
|
|
324
|
+
if (res.headersSent) return;
|
|
325
|
+
if (!e.status) console.error(`Error in ${req.method} ${req.path}:`, e);
|
|
326
|
+
res.status(e.status || 500).json({ error: e.status ? e.message : 'Internal error' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
app.post(SHOW_PATH, route(async (req, res) => {
|
|
330
|
+
if (guard(req, res)) return;
|
|
331
|
+
const p = parsePost(req.body);
|
|
332
|
+
if (typeof p === 'string') return res.status(400).json({ error: p });
|
|
333
|
+
if (p.file) {
|
|
334
|
+
try {
|
|
335
|
+
p.kind = (await readFilePost(p.file)).kind;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
return res.status(400).json({ error: e.message });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
res.json(store.post(req.params.terminalId, p));
|
|
341
|
+
}));
|
|
342
|
+
|
|
343
|
+
app.get(SHOW_PATH, (req, res) => {
|
|
344
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
345
|
+
res.json(store.list(req.params.terminalId));
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
app.get(`${SHOW_PATH}/:postId`, route(async (req, res) => {
|
|
349
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
350
|
+
const found = store.get(req.params.terminalId, req.params.postId);
|
|
351
|
+
if (!found) return res.status(404).json({ error: 'no such post' });
|
|
352
|
+
const { entry } = found;
|
|
353
|
+
const out = { id: entry.id, title: entry.title, key: entry.key, kind: entry.kind, content: found.content, file: entry.file, path: found.path, url: null };
|
|
354
|
+
if (found.waiting) out.waiting = true;
|
|
355
|
+
if (entry.file) {
|
|
356
|
+
const { content, kind } = await readFilePost(entry.file);
|
|
357
|
+
out.kind = kind;
|
|
358
|
+
out.content = content;
|
|
359
|
+
if (kind === 'image') out.url = `/api/preview/image?path=${encodeURIComponent(entry.file)}`;
|
|
360
|
+
}
|
|
361
|
+
res.json(out);
|
|
362
|
+
}));
|
|
363
|
+
|
|
364
|
+
app.delete(SHOW_PATH, route(async (req, res) => {
|
|
365
|
+
if (guard(req, res)) return;
|
|
366
|
+
const { sessionId, paths } = store.clear(req.params.terminalId);
|
|
367
|
+
if (sessionId) {
|
|
368
|
+
o.broadcast({ type: 'show:cleared', terminalId: req.params.terminalId, sessionId });
|
|
369
|
+
if (paths.length) o.onRemoved?.(sessionId, paths);
|
|
370
|
+
}
|
|
371
|
+
res.status(204).end();
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
app.delete(`${SHOW_PATH}/:postId`, route(async (req, res) => {
|
|
375
|
+
if (guard(req, res)) return;
|
|
376
|
+
const { terminalId, postId } = req.params;
|
|
377
|
+
const removed = store.remove(terminalId, postId);
|
|
378
|
+
if (!removed) return res.status(404).json({ error: 'no such post' });
|
|
379
|
+
o.broadcast({ type: 'show:removed', terminalId, sessionId: removed.sessionId, id: postId, count: removed.count });
|
|
380
|
+
o.onRemoved?.(removed.sessionId, [removed.path]);
|
|
381
|
+
res.status(204).end();
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = { createShowStore, mountShowRoutes, showBodyParser, parsePost, SHOW_PATH };
|
package/lib/terminal-client.js
CHANGED
|
@@ -41,6 +41,7 @@ function createTerminalClient(o) {
|
|
|
41
41
|
let child = null;
|
|
42
42
|
let hostReason = null;
|
|
43
43
|
let ids = [];
|
|
44
|
+
let terminalIds = [];
|
|
44
45
|
let serverUrl = null;
|
|
45
46
|
let restoreAsked = false;
|
|
46
47
|
let stopped = false;
|
|
@@ -76,6 +77,7 @@ function createTerminalClient(o) {
|
|
|
76
77
|
if (msg.t === 'state') {
|
|
77
78
|
hostReason = msg.reason;
|
|
78
79
|
ids = msg.ids;
|
|
80
|
+
terminalIds = msg.terminalIds || [];
|
|
79
81
|
settle(hostReason);
|
|
80
82
|
o.onChange?.();
|
|
81
83
|
} else if (msg.t === 'exit') o.onExit?.(msg.id);
|
|
@@ -118,6 +120,7 @@ function createTerminalClient(o) {
|
|
|
118
120
|
console.log(`Terminal host exited (${signal || code})`);
|
|
119
121
|
const lost = ids;
|
|
120
122
|
ids = [];
|
|
123
|
+
terminalIds = [];
|
|
121
124
|
for (const id of lost) o.onExit?.(id);
|
|
122
125
|
o.onChange?.();
|
|
123
126
|
const now = Date.now();
|
|
@@ -162,6 +165,16 @@ function createTerminalClient(o) {
|
|
|
162
165
|
return ids.includes(id);
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
function hasTerminal(terminalId) {
|
|
169
|
+
return terminalIds.includes(terminalId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The host sends both lists from one array of terminals.
|
|
173
|
+
function sessionOfTerminal(terminalId) {
|
|
174
|
+
const at = terminalIds.indexOf(terminalId);
|
|
175
|
+
return at < 0 ? null : ids[at];
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
async function startNew(msg, extraEnv) {
|
|
166
179
|
if (localReason) return { status: 403, error: localReason };
|
|
167
180
|
return call('startNew', msg, extraEnv);
|
|
@@ -212,7 +225,7 @@ function createTerminalClient(o) {
|
|
|
212
225
|
else spawn();
|
|
213
226
|
|
|
214
227
|
return {
|
|
215
|
-
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, startNew, end, paste, restore,
|
|
228
|
+
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
|
|
216
229
|
setServerUrl, sessions, stats, shutdown, started,
|
|
217
230
|
ids: () => [...ids],
|
|
218
231
|
};
|
package/lib/terminal-host.js
CHANGED
|
@@ -27,7 +27,8 @@ function ask(op, arg) {
|
|
|
27
27
|
|
|
28
28
|
// The service reports every attach and detach too; cck needs only a change of ids or reason.
|
|
29
29
|
function pushState() {
|
|
30
|
-
const
|
|
30
|
+
const live = service.list();
|
|
31
|
+
const state = { t: 'state', reason: service.unavailableReason(), ids: live.map((s) => s.id), terminalIds: live.map((s) => s.terminalId) };
|
|
31
32
|
const key = JSON.stringify(state);
|
|
32
33
|
if (key === lastState) return;
|
|
33
34
|
lastState = key;
|
package/lib/terminal.js
CHANGED
|
@@ -74,6 +74,8 @@ const STRIP_ENV = [
|
|
|
74
74
|
// session's child and writes no live-session registry entry, which the resume picker relies on.
|
|
75
75
|
'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_SESSION_ATTENDED',
|
|
76
76
|
'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_EXECPATH',
|
|
77
|
+
// The parent session's effort level; a terminal session takes its own from settings or --effort.
|
|
78
|
+
'CLAUDE_EFFORT',
|
|
77
79
|
// A shared task list is opt-in per dispatch (taskList), never inherited from cck's own env.
|
|
78
80
|
'CLAUDE_CODE_TASK_LIST_ID',
|
|
79
81
|
// A cck started inside tmux passes these on, and claude then lists its session under a tmux pane it is not in.
|
|
@@ -252,14 +254,17 @@ function screenText(term) {
|
|
|
252
254
|
// Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
|
|
253
255
|
// inside the dir when set, so the default dir must stay unset, not be spelled out.
|
|
254
256
|
// CCK_URL pins the CLI and the doorbell mod to this board: two boards on one config dir share
|
|
255
|
-
// one server.json, and the last one started owns it.
|
|
256
|
-
|
|
257
|
+
// one server.json, and the last one started owns it. CCK_TERMINAL_ID names the terminal the
|
|
258
|
+
// show tool posts to.
|
|
259
|
+
function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl, terminalId }) {
|
|
257
260
|
const env = { ...process.env };
|
|
258
261
|
for (const k of STRIP_ENV) delete env[k];
|
|
259
262
|
if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
|
|
260
263
|
else env.CLAUDE_CONFIG_DIR = claudeDir;
|
|
261
264
|
if (cckUrl) env.CCK_URL = cckUrl;
|
|
262
265
|
else delete env.CCK_URL;
|
|
266
|
+
if (terminalId) env.CCK_TERMINAL_ID = terminalId;
|
|
267
|
+
else delete env.CCK_TERMINAL_ID;
|
|
263
268
|
// xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
|
|
264
269
|
if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
|
|
265
270
|
env.TERM = 'xterm-256color';
|
|
@@ -360,6 +365,11 @@ function createTerminalService(o) {
|
|
|
360
365
|
for (const [id, list] of Object.entries(savedState?.taskLists || {})) {
|
|
361
366
|
if (restoreQueue.includes(id) && typeof list === 'string' && TASK_LIST_RE.test(list)) savedTaskLists[id] = list;
|
|
362
367
|
}
|
|
368
|
+
// A resumed terminal keeps its id, so its show card keeps its posts.
|
|
369
|
+
const savedTerminalIds = {};
|
|
370
|
+
for (const [id, terminalId] of Object.entries(savedState?.terminalIds || {})) {
|
|
371
|
+
if (restoreQueue.includes(id) && typeof terminalId === 'string' && UUID_RE.test(terminalId)) savedTerminalIds[id] = terminalId;
|
|
372
|
+
}
|
|
363
373
|
|
|
364
374
|
// Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
|
|
365
375
|
function load() {
|
|
@@ -402,13 +412,22 @@ function createTerminalService(o) {
|
|
|
402
412
|
saveTimer = null;
|
|
403
413
|
const ids = new Set(restoreQueue);
|
|
404
414
|
const taskLists = {};
|
|
405
|
-
|
|
415
|
+
const terminalIds = {};
|
|
416
|
+
for (const id of restoreQueue) {
|
|
417
|
+
if (savedTaskLists[id]) taskLists[id] = savedTaskLists[id];
|
|
418
|
+
if (savedTerminalIds[id]) terminalIds[id] = savedTerminalIds[id];
|
|
419
|
+
}
|
|
406
420
|
for (const s of sessions.values()) {
|
|
407
421
|
if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode)) continue;
|
|
408
422
|
ids.add(s.id);
|
|
409
423
|
if (s.taskList) taskLists[s.id] = s.taskList;
|
|
424
|
+
terminalIds[s.id] = s.terminalId;
|
|
410
425
|
}
|
|
411
|
-
const data = {
|
|
426
|
+
const data = {
|
|
427
|
+
sessions: [...ids],
|
|
428
|
+
...(Object.keys(taskLists).length && { taskLists }),
|
|
429
|
+
...(Object.keys(terminalIds).length && { terminalIds }),
|
|
430
|
+
};
|
|
412
431
|
const json = JSON.stringify(data);
|
|
413
432
|
if (json === lastSaved) return;
|
|
414
433
|
lastSaved = json;
|
|
@@ -556,7 +575,8 @@ function createTerminalService(o) {
|
|
|
556
575
|
|
|
557
576
|
function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
|
|
558
577
|
const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
|
|
559
|
-
const
|
|
578
|
+
const terminalId = savedTerminalIds[id] || crypto.randomUUID();
|
|
579
|
+
const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.(), terminalId }), ...extraEnv };
|
|
560
580
|
const taskList = spec?.taskList || savedTaskLists[id] || null;
|
|
561
581
|
if (taskList) env.CLAUDE_CODE_TASK_LIST_ID = taskList;
|
|
562
582
|
// Git Bash's login profile cds to $HOME unless this is set.
|
|
@@ -573,7 +593,7 @@ function createTerminalService(o) {
|
|
|
573
593
|
const serializer = new Serialize();
|
|
574
594
|
term.loadAddon(serializer);
|
|
575
595
|
const s = {
|
|
576
|
-
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
596
|
+
id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
577
597
|
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
578
598
|
forkOf: spec?.forkOf || null,
|
|
579
599
|
};
|
|
@@ -782,7 +802,7 @@ function createTerminalService(o) {
|
|
|
782
802
|
|
|
783
803
|
function list() {
|
|
784
804
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
785
|
-
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
805
|
+
id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
786
806
|
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
|
|
787
807
|
}));
|
|
788
808
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -37,31 +37,87 @@ async function findBoard($: EngineInterface) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
type Line = { text: string; seq?: number }
|
|
41
|
+
|
|
42
|
+
// A board before acks sends no seqs and drops a line once it answers with it.
|
|
43
|
+
export function parseReply(text: string) {
|
|
44
|
+
const { events, seqs, board } = JSON.parse(text) as { events?: unknown; seqs?: unknown; board?: unknown }
|
|
45
|
+
const lines: Line[] = Array.isArray(events)
|
|
46
|
+
? events.flatMap((e, i) =>
|
|
47
|
+
typeof e === 'string'
|
|
48
|
+
? [{ text: e, seq: Array.isArray(seqs) && Number.isInteger(seqs[i]) ? (seqs[i] as number) : undefined }]
|
|
49
|
+
: [],
|
|
50
|
+
)
|
|
51
|
+
: []
|
|
52
|
+
return { lines, board: typeof board === 'string' ? board : '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function poll($: EngineInterface, url: string, sessionId: string, query: string) {
|
|
42
56
|
const r = await $.http.fetch(`${url}/api/sessions/${encodeURIComponent(sessionId)}/events?${query}`)
|
|
43
57
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
44
|
-
|
|
45
|
-
return Array.isArray(events) ? events.filter((e): e is string => typeof e === 'string') : []
|
|
58
|
+
return parseReply(r.text)
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
// The board keeps a line until a poll acks it, and the ack goes out only after the submit, so a
|
|
62
|
+
// line the mod never got to the session is sent again. Seqs count per board run: `board` names
|
|
63
|
+
// the run, and the board ignores `got` and `ack` from another one.
|
|
64
|
+
//
|
|
48
65
|
// The session id changes on /clear, so it is read for every poll. The first poll for an id drops
|
|
49
|
-
//
|
|
66
|
+
// the task moves the board queued before this session listened (a move from hours ago is not an
|
|
67
|
+
// instruction) and names the id it leaves, so the board moves that id's reviews over.
|
|
50
68
|
async function listen($: EngineInterface) {
|
|
51
69
|
let attached: string | undefined
|
|
70
|
+
let board = ''
|
|
71
|
+
let got = 0
|
|
72
|
+
let acked = 0
|
|
73
|
+
const buffer: Line[] = []
|
|
74
|
+
let submitting = false
|
|
75
|
+
|
|
76
|
+
// A submit waits until the session is idle. The loop keeps polling meanwhile, so the board sees
|
|
77
|
+
// a live doorbell, and lines that come in wait here to go out as one prompt.
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (submitting || !buffer.length) return
|
|
80
|
+
const burst = buffer.splice(0)
|
|
81
|
+
const forBoard = board
|
|
82
|
+
submitting = true
|
|
83
|
+
void $.prompt
|
|
84
|
+
.submit({ text: burst.map(l => l.text).join('\n') })
|
|
85
|
+
.catch(() => {})
|
|
86
|
+
.finally(() => {
|
|
87
|
+
if (forBoard === board) for (const l of burst) if (l.seq !== undefined && l.seq > acked) acked = l.seq
|
|
88
|
+
submitting = false
|
|
89
|
+
flush()
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
for (;;) {
|
|
53
94
|
const [url, sessionId] = await Promise.all([findBoard($), $.session.id()])
|
|
54
|
-
|
|
95
|
+
const first = attached !== sessionId
|
|
96
|
+
const query = [`wait=${WAIT_SEC}`, `board=${encodeURIComponent(board)}`, `got=${got}`, `ack=${acked}`]
|
|
97
|
+
if (first) query.push('first=1')
|
|
98
|
+
if (first && attached) query.push(`prev=${encodeURIComponent(attached)}`)
|
|
99
|
+
let reply: ReturnType<typeof parseReply> | undefined
|
|
55
100
|
try {
|
|
56
|
-
if (url)
|
|
101
|
+
if (url) reply = await poll($, url, sessionId, query.join('&'))
|
|
57
102
|
} catch {}
|
|
58
|
-
if (!
|
|
103
|
+
if (!reply) {
|
|
59
104
|
await $.clock.sleep(RETRY_MS)
|
|
60
105
|
continue
|
|
61
106
|
}
|
|
62
107
|
attached = sessionId
|
|
63
|
-
|
|
64
|
-
|
|
108
|
+
if (reply.board !== board) {
|
|
109
|
+
board = reply.board
|
|
110
|
+
got = 0
|
|
111
|
+
acked = 0
|
|
112
|
+
}
|
|
113
|
+
for (const line of reply.lines) {
|
|
114
|
+
if (line.seq !== undefined) {
|
|
115
|
+
if (line.seq <= got) continue
|
|
116
|
+
got = line.seq
|
|
117
|
+
}
|
|
118
|
+
buffer.push(line)
|
|
119
|
+
}
|
|
120
|
+
flush()
|
|
65
121
|
}
|
|
66
122
|
}
|
|
67
123
|
|
|
@@ -2,9 +2,11 @@ import type { Register } from 'claude-code'
|
|
|
2
2
|
import { register as activity } from './activity'
|
|
3
3
|
import { register as context } from './context'
|
|
4
4
|
import { register as doorbell } from './doorbell'
|
|
5
|
+
import { register as show } from './show'
|
|
5
6
|
|
|
6
7
|
export const register: Register = (on, options) => {
|
|
7
8
|
context(on, options)
|
|
8
9
|
activity(on, options)
|
|
9
10
|
doorbell(on, options)
|
|
11
|
+
show(on, options)
|
|
10
12
|
}
|