claude-code-kanban 5.4.0 → 6.1.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 +23 -13
- package/cli.js +20 -103
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +16 -1
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +101 -42
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +5 -2
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -7
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +14 -1
- package/public/app.js +384 -93
- package/public/index.html +14 -3
- package/public/project-match.js +6 -1
- package/public/style.css +110 -0
- package/server.js +102 -119
- package/skill-guides/dispatch.md +16 -54
package/lib/parsers.js
CHANGED
|
@@ -373,13 +373,20 @@ const modeOf = (data) => {
|
|
|
373
373
|
return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
|
|
374
374
|
};
|
|
375
375
|
|
|
376
|
+
// The TTL of the last prompt-cache write, from the API's 5m/1h split of cache_creation.
|
|
377
|
+
const cacheTtlOf = (data) => {
|
|
378
|
+
const cc = data.type === 'assistant' && !data.isSidechain && data.message?.usage?.cache_creation;
|
|
379
|
+
if (!cc) return null;
|
|
380
|
+
return cc.ephemeral_1h_input_tokens > 0 ? '1h' : cc.ephemeral_5m_input_tokens > 0 ? '5m' : null;
|
|
381
|
+
};
|
|
382
|
+
|
|
376
383
|
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
377
384
|
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
378
385
|
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
379
386
|
// change or truncation (size < scannedUpTo).
|
|
380
387
|
|
|
381
388
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
382
|
-
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null };
|
|
389
|
+
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null };
|
|
383
390
|
let stat;
|
|
384
391
|
let fd;
|
|
385
392
|
try {
|
|
@@ -400,6 +407,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
400
407
|
logicalParentUuid: cached.logicalParentUuid || null,
|
|
401
408
|
compactBoundaryUuid: cached.compactBoundaryUuid || null,
|
|
402
409
|
permissionMode: cached.permissionMode || null,
|
|
410
|
+
cacheTtl: cached.cacheTtl || null,
|
|
403
411
|
customTitle: readCustomTitle(jsonlPath, stat)
|
|
404
412
|
};
|
|
405
413
|
}
|
|
@@ -412,6 +420,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
412
420
|
result.logicalParentUuid = cached.logicalParentUuid || null;
|
|
413
421
|
result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
|
|
414
422
|
result.permissionMode = cached.permissionMode || null;
|
|
423
|
+
result.cacheTtl = cached.cacheTtl || null;
|
|
415
424
|
}
|
|
416
425
|
|
|
417
426
|
let lastCwdSeen = result.cwd;
|
|
@@ -426,6 +435,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
426
435
|
if (data.gitBranch) result.gitBranch = data.gitBranch;
|
|
427
436
|
const mode = modeOf(data);
|
|
428
437
|
if (mode) result.permissionMode = mode;
|
|
438
|
+
const ttl = cacheTtlOf(data);
|
|
439
|
+
if (ttl) result.cacheTtl = ttl;
|
|
429
440
|
if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
|
|
430
441
|
result.logicalParentUuid = data.logicalParentUuid;
|
|
431
442
|
// The boundary record's own uuid: a fork copies it verbatim from the
|
|
@@ -498,19 +509,22 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
498
509
|
const lines = tailBuf.toString('utf8', 0, tn).split('\n');
|
|
499
510
|
let latestTailCwd = null;
|
|
500
511
|
let latestTailMode = null;
|
|
512
|
+
let latestTailTtl = null;
|
|
501
513
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
502
514
|
try {
|
|
503
515
|
const data = JSON.parse(lines[i]);
|
|
516
|
+
if (!latestTailTtl) latestTailTtl = cacheTtlOf(data);
|
|
504
517
|
if (!result.slug && data.slug) result.slug = data.slug;
|
|
505
518
|
if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
|
|
506
519
|
if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
|
|
507
520
|
if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
|
|
508
521
|
if (!latestTailMode) latestTailMode = modeOf(data);
|
|
509
|
-
if (latestTailCwd && latestTailMode && result.slug && result.projectPath && result.gitBranch) break;
|
|
522
|
+
if (latestTailCwd && latestTailMode && latestTailTtl && result.slug && result.projectPath && result.gitBranch) break;
|
|
510
523
|
} catch {}
|
|
511
524
|
}
|
|
512
525
|
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
513
526
|
if (latestTailMode) result.permissionMode = latestTailMode;
|
|
527
|
+
if (latestTailTtl) result.cacheTtl = latestTailTtl;
|
|
514
528
|
}
|
|
515
529
|
scannedUpTo = stat.size;
|
|
516
530
|
}
|
|
@@ -533,7 +547,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
533
547
|
cwd: result.cwd,
|
|
534
548
|
logicalParentUuid: result.logicalParentUuid,
|
|
535
549
|
compactBoundaryUuid: result.compactBoundaryUuid,
|
|
536
|
-
permissionMode: result.permissionMode
|
|
550
|
+
permissionMode: result.permissionMode,
|
|
551
|
+
cacheTtl: result.cacheTtl
|
|
537
552
|
});
|
|
538
553
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
539
554
|
const firstKey = sessionInfoCache.keys().next().value;
|
package/lib/retention.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
const fs = require('node:fs/promises');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { readSettings } = require('./claude-settings');
|
|
6
|
-
const { DISPATCH_OUTCOME } = require('./dispatch');
|
|
7
6
|
|
|
8
7
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
8
|
const DEFAULT_CLEANUP_DAYS = 30;
|
|
@@ -11,7 +10,6 @@ const DEFAULT_CLEANUP_DAYS = 30;
|
|
|
11
10
|
// read that gap as a deleted transcript.
|
|
12
11
|
const GRACE_MS = 60 * 60 * 1000;
|
|
13
12
|
const MAX_DISPATCHED = 500;
|
|
14
|
-
const OUTCOMES = new Set(Object.keys(DISPATCH_OUTCOME));
|
|
15
13
|
|
|
16
14
|
// Only the config dir's settings: retention is per config dir, not per project.
|
|
17
15
|
function retentionMs(claudeDir) {
|
|
@@ -28,10 +26,10 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
/**
|
|
31
|
-
* Sessions started through `dispatch start`, kept after
|
|
29
|
+
* Sessions started through `dispatch start`, kept after their terminal ends so the card
|
|
32
30
|
* keeps its marker.
|
|
33
31
|
* @param {object} o
|
|
34
|
-
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent,
|
|
32
|
+
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
|
|
35
33
|
* @param {(data: object) => void} o.save
|
|
36
34
|
*/
|
|
37
35
|
function createDispatchedStore({ load, save, now = Date.now }) {
|
|
@@ -41,10 +39,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
41
39
|
if (saved && typeof saved === 'object') {
|
|
42
40
|
for (const [id, e] of Object.entries(saved)) {
|
|
43
41
|
if (!e || !Number.isFinite(e.at)) continue;
|
|
44
|
-
|
|
45
|
-
// stopped can never settle, so it reads as ended.
|
|
46
|
-
const status = OUTCOMES.has(e.status) ? e.status : 'exited';
|
|
47
|
-
entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, status, at: e.at });
|
|
42
|
+
entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
|
|
48
43
|
}
|
|
49
44
|
}
|
|
50
45
|
|
|
@@ -52,18 +47,11 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
52
47
|
|
|
53
48
|
function record(id, parent) {
|
|
54
49
|
entries.delete(id);
|
|
55
|
-
entries.set(id, { parent: parent || null,
|
|
50
|
+
entries.set(id, { parent: parent || null, at: now() });
|
|
56
51
|
if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
|
|
57
52
|
persist();
|
|
58
53
|
}
|
|
59
54
|
|
|
60
|
-
function settle(id, status) {
|
|
61
|
-
const e = entries.get(id);
|
|
62
|
-
if (!e || e.status !== 'running' || !OUTCOMES.has(status)) return;
|
|
63
|
-
e.status = status;
|
|
64
|
-
persist();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
55
|
// Returns how many entries went; writes only when one did.
|
|
68
56
|
function prune({ known, maxAgeMs }) {
|
|
69
57
|
const t = now();
|
|
@@ -78,7 +66,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
78
66
|
return removed;
|
|
79
67
|
}
|
|
80
68
|
|
|
81
|
-
return { record,
|
|
69
|
+
return { record, prune, get: (id) => entries.get(id) || null };
|
|
82
70
|
}
|
|
83
71
|
|
|
84
72
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
package/lib/session-cache.js
CHANGED
|
@@ -4,7 +4,7 @@ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = requ
|
|
|
4
4
|
const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
|
|
5
5
|
|
|
6
6
|
// Bump when an entry's shape or meaning changes: a file with another version is ignored.
|
|
7
|
-
const VERSION =
|
|
7
|
+
const VERSION = 4;
|
|
8
8
|
// A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
|
|
9
9
|
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
10
|
|
package/lib/session-events.js
CHANGED
|
@@ -79,8 +79,9 @@ function clampWait(sec) {
|
|
|
79
79
|
return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Each topic rides its own bucket, so
|
|
83
|
-
//
|
|
82
|
+
// Each topic rides its own bucket, so a postman on a topic never prints task moves the
|
|
83
|
+
// user did not grant with the follow skill. No topic is the task-move bucket. Plugins
|
|
84
|
+
// before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
|
|
84
85
|
function topicKey(topic, sessionId) {
|
|
85
86
|
return topic ? `${topic}:${sessionId}` : sessionId;
|
|
86
87
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// cck's side of the terminal host (lib/terminal-host.js). cck keeps the gates it can check
|
|
4
|
+
// alone (enabled, exposure, Host and Origin, the token) and the ids the host last reported,
|
|
5
|
+
// so the board's sync callers stay sync; everything else is a call over IPC.
|
|
6
|
+
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { fork } = require('node:child_process');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { tokenMatches, localUnavailableReason, refuseUpgrade, clientConfigFor } = require('./terminal');
|
|
11
|
+
|
|
12
|
+
const HOST = path.join(__dirname, 'terminal-host.js');
|
|
13
|
+
const CALL_TIMEOUT_MS = 15000;
|
|
14
|
+
const MAX_RESTARTS = 3;
|
|
15
|
+
const RESTART_WINDOW_MS = 60000;
|
|
16
|
+
const RESTART_DELAY_MS = 1000;
|
|
17
|
+
|
|
18
|
+
function hostError(message) {
|
|
19
|
+
return Object.assign(new Error(message), { status: 503 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} o
|
|
24
|
+
* @param {ReturnType<typeof import('./terminal').readTerminalConfig>} o.config
|
|
25
|
+
* @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
|
|
26
|
+
* @param {string} o.claudeDir
|
|
27
|
+
* @param {boolean} o.isDefaultDir
|
|
28
|
+
* @param {string} o.sessionsDir claude's live-session registry
|
|
29
|
+
* @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd
|
|
30
|
+
* @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder
|
|
31
|
+
* @param {string} [o.token]
|
|
32
|
+
* @param {() => {sessions?: string[]}|null} [o.load]
|
|
33
|
+
* @param {(data: {sessions: string[]}) => void} [o.save]
|
|
34
|
+
* @param {() => void} [o.onChange]
|
|
35
|
+
* @param {(id: string) => void} [o.onExit]
|
|
36
|
+
*/
|
|
37
|
+
function createTerminalClient(o) {
|
|
38
|
+
const { config, net } = o;
|
|
39
|
+
const token = o.token || crypto.randomBytes(32).toString('hex');
|
|
40
|
+
const localReason = localUnavailableReason(config, net);
|
|
41
|
+
let child = null;
|
|
42
|
+
let hostReason = null;
|
|
43
|
+
let ids = [];
|
|
44
|
+
let serverUrl = null;
|
|
45
|
+
let restoreAsked = false;
|
|
46
|
+
let stopped = false;
|
|
47
|
+
let shuttingDown = false;
|
|
48
|
+
const restarts = [];
|
|
49
|
+
let nextCall = 0;
|
|
50
|
+
const calls = new Map();
|
|
51
|
+
let settle;
|
|
52
|
+
const started = new Promise((resolve) => { settle = resolve; });
|
|
53
|
+
|
|
54
|
+
function unavailableReason() {
|
|
55
|
+
if (localReason) return localReason;
|
|
56
|
+
if (stopped) return 'the terminal host stopped';
|
|
57
|
+
if (!child) return 'the terminal host is restarting';
|
|
58
|
+
return hostReason;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function spawn() {
|
|
62
|
+
hostReason = null;
|
|
63
|
+
const c = fork(HOST, [], { execArgv: [], windowsHide: true });
|
|
64
|
+
child = c;
|
|
65
|
+
c.on('message', (msg) => { if (c === child) onMessage(c, msg); });
|
|
66
|
+
c.on('exit', (code, signal) => { if (c === child) onHostExit(code, signal); });
|
|
67
|
+
c.on('error', () => {});
|
|
68
|
+
c.send({
|
|
69
|
+
t: 'init', config, claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, sessionsDir: o.sessionsDir, token, serverUrl,
|
|
70
|
+
saved: config.restore ? o.load?.() ?? null : null,
|
|
71
|
+
});
|
|
72
|
+
if (restoreAsked) call('restore').catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function onMessage(c, msg) {
|
|
76
|
+
if (msg.t === 'state') {
|
|
77
|
+
hostReason = msg.reason;
|
|
78
|
+
ids = msg.ids;
|
|
79
|
+
settle(hostReason);
|
|
80
|
+
o.onChange?.();
|
|
81
|
+
} else if (msg.t === 'exit') o.onExit?.(msg.id);
|
|
82
|
+
else if (msg.t === 'save') o.save?.(msg.data);
|
|
83
|
+
else if (msg.t === 'reply') {
|
|
84
|
+
const pending = calls.get(msg.id);
|
|
85
|
+
if (!pending) return;
|
|
86
|
+
calls.delete(msg.id);
|
|
87
|
+
clearTimeout(pending.timer);
|
|
88
|
+
if (msg.error) pending.reject(new Error(msg.error));
|
|
89
|
+
else pending.resolve(msg.value);
|
|
90
|
+
} else if (msg.t === 'ask') answer(c, msg);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ASKS = {
|
|
94
|
+
resolveCwd: [o.resolveCwd, null],
|
|
95
|
+
isAllowedFolder: [o.isAllowedFolder, false],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function answer(c, msg) {
|
|
99
|
+
const [fn, fallback] = ASKS[msg.op];
|
|
100
|
+
let value;
|
|
101
|
+
try {
|
|
102
|
+
value = await fn(msg.arg);
|
|
103
|
+
} catch {
|
|
104
|
+
value = fallback;
|
|
105
|
+
}
|
|
106
|
+
if (c.connected) c.send({ t: 'answer', id: msg.id, value });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The PTYs die with the host, as they do with cck; a new host resumes terminals.json.
|
|
110
|
+
function onHostExit(code, signal) {
|
|
111
|
+
child = null;
|
|
112
|
+
for (const pending of calls.values()) {
|
|
113
|
+
clearTimeout(pending.timer);
|
|
114
|
+
pending.reject(hostError('the terminal host stopped'));
|
|
115
|
+
}
|
|
116
|
+
calls.clear();
|
|
117
|
+
if (shuttingDown) return;
|
|
118
|
+
console.log(`Terminal host exited (${signal || code})`);
|
|
119
|
+
const lost = ids;
|
|
120
|
+
ids = [];
|
|
121
|
+
for (const id of lost) o.onExit?.(id);
|
|
122
|
+
o.onChange?.();
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
while (restarts.length && now - restarts[0] > RESTART_WINDOW_MS) restarts.shift();
|
|
125
|
+
if (restarts.length >= MAX_RESTARTS) {
|
|
126
|
+
stopped = true;
|
|
127
|
+
console.log(`Terminal unavailable: the host stopped ${MAX_RESTARTS} times in ${RESTART_WINDOW_MS / 1000} s`);
|
|
128
|
+
settle(unavailableReason());
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
restarts.push(now);
|
|
132
|
+
setTimeout(() => { if (!shuttingDown) spawn(); }, RESTART_DELAY_MS * restarts.length).unref();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function call(op, ...args) {
|
|
136
|
+
const c = child;
|
|
137
|
+
if (!c?.connected) return Promise.reject(hostError(unavailableReason() || 'the terminal host is not running'));
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const id = ++nextCall;
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
calls.delete(id);
|
|
142
|
+
reject(hostError('the terminal host did not answer'));
|
|
143
|
+
}, CALL_TIMEOUT_MS);
|
|
144
|
+
calls.set(id, { resolve, reject, timer });
|
|
145
|
+
c.send({ t: 'call', id, op, args });
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleUpgrade(req, socket, head) {
|
|
150
|
+
if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
|
|
151
|
+
// The client sends nothing before the 101, so no byte is lost between the two processes.
|
|
152
|
+
child.send({ t: 'upgrade', method: req.method, url: req.url, headers: req.headers, head: head.toString('base64') }, socket, (err) => {
|
|
153
|
+
if (err) socket.destroy();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function authorized(providedToken) {
|
|
158
|
+
return tokenMatches(token, providedToken);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isRunning(id) {
|
|
162
|
+
return ids.includes(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function startNew(msg, extraEnv) {
|
|
166
|
+
if (localReason) return { status: 403, error: localReason };
|
|
167
|
+
return call('startNew', msg, extraEnv);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function end(id, providedToken) {
|
|
171
|
+
if (!authorized(providedToken)) return 'auth';
|
|
172
|
+
if (localReason) return 'not-found';
|
|
173
|
+
return call('end', id, providedToken);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function paste(id, text) {
|
|
177
|
+
if (!isRunning(id)) return false;
|
|
178
|
+
return call('paste', id, text).catch(() => false);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function restore() {
|
|
182
|
+
restoreAsked = true;
|
|
183
|
+
if (child) call('restore').catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setServerUrl(url) {
|
|
187
|
+
serverUrl = url;
|
|
188
|
+
if (child?.connected) child.send({ t: 'port', url });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// With no host there are no terminals, which the hub's eviction check must still read.
|
|
192
|
+
async function sessions() {
|
|
193
|
+
return child?.connected ? call('sessions') : [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function stats() {
|
|
197
|
+
const c = child;
|
|
198
|
+
return { hostPid: c?.pid ?? null, claudePids: c?.connected ? await call('claudePids') : {} };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function clientConfig() {
|
|
202
|
+
return clientConfigFor(config, !unavailableReason());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Closing the channel is what ends the host, so it ends its PTYs itself.
|
|
206
|
+
function shutdown() {
|
|
207
|
+
shuttingDown = true;
|
|
208
|
+
try { child?.disconnect(); } catch { /* already closed */ }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (localReason) settle(localReason);
|
|
212
|
+
else spawn();
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, startNew, end, paste, restore,
|
|
216
|
+
setServerUrl, sessions, stats, shutdown, started,
|
|
217
|
+
ids: () => [...ids],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { createTerminalClient };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The terminal's own process, forked by lib/terminal-client.js. It runs the terminal
|
|
4
|
+
// service unchanged; cck reaches it only over the IPC channel and ends it by closing that.
|
|
5
|
+
|
|
6
|
+
const { createTerminalService } = require('./terminal');
|
|
7
|
+
const { readLiveSessions, isSessionLive } = require('./live-sessions');
|
|
8
|
+
const { whichSync } = require('./open-editor');
|
|
9
|
+
|
|
10
|
+
let service = null;
|
|
11
|
+
let serverUrl = null;
|
|
12
|
+
let lastState = null;
|
|
13
|
+
let nextAsk = 0;
|
|
14
|
+
const asks = new Map();
|
|
15
|
+
|
|
16
|
+
function send(msg) {
|
|
17
|
+
if (process.connected) process.send(msg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ask(op, arg) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const id = ++nextAsk;
|
|
23
|
+
asks.set(id, resolve);
|
|
24
|
+
send({ t: 'ask', id, op, arg });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The service reports every attach and detach too; cck needs only a change of ids or reason.
|
|
29
|
+
function pushState() {
|
|
30
|
+
const state = { t: 'state', reason: service.unavailableReason(), ids: service.list().map((s) => s.id) };
|
|
31
|
+
const key = JSON.stringify(state);
|
|
32
|
+
if (key === lastState) return;
|
|
33
|
+
lastState = key;
|
|
34
|
+
send(state);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function init(msg) {
|
|
38
|
+
serverUrl = msg.serverUrl;
|
|
39
|
+
const liveSessions = () => readLiveSessions(msg.sessionsDir);
|
|
40
|
+
service = createTerminalService({
|
|
41
|
+
config: msg.config,
|
|
42
|
+
// cck checked Host, Origin and exposure before it handed the socket over.
|
|
43
|
+
net: { EXPOSED: false, upgradeVerdict: () => null },
|
|
44
|
+
claudeDir: msg.claudeDir,
|
|
45
|
+
isDefaultDir: msg.isDefaultDir,
|
|
46
|
+
token: msg.token,
|
|
47
|
+
serverUrl: () => serverUrl,
|
|
48
|
+
which: whichSync,
|
|
49
|
+
liveSessions,
|
|
50
|
+
isLiveElsewhere: (id, exceptPid) => isSessionLive(liveSessions(), id, exceptPid),
|
|
51
|
+
resolveCwd: (id) => ask('resolveCwd', id),
|
|
52
|
+
isAllowedFolder: (dir) => ask('isAllowedFolder', dir),
|
|
53
|
+
load: () => msg.saved,
|
|
54
|
+
save: (data) => send({ t: 'save', data }),
|
|
55
|
+
onChange: pushState,
|
|
56
|
+
onExit: (id) => send({ t: 'exit', id }),
|
|
57
|
+
});
|
|
58
|
+
pushState();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CALLS = {
|
|
62
|
+
startNew: (msg, extraEnv) => service.startNew(msg, extraEnv),
|
|
63
|
+
end: (id, token) => service.end(id, token),
|
|
64
|
+
paste: (id, text) => service.paste(id, text),
|
|
65
|
+
restore: () => service.restore(),
|
|
66
|
+
sessions: () => service.list(),
|
|
67
|
+
claudePids: () => service.claudePids(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function call(msg) {
|
|
71
|
+
try {
|
|
72
|
+
send({ t: 'reply', id: msg.id, value: await CALLS[msg.op](...msg.args) });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
send({ t: 'reply', id: msg.id, error: e.message });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
process.on('message', (msg, socket) => {
|
|
79
|
+
if (msg.t === 'init') init(msg);
|
|
80
|
+
else if (msg.t === 'port') serverUrl = msg.url;
|
|
81
|
+
else if (msg.t === 'upgrade') {
|
|
82
|
+
if (!socket) return;
|
|
83
|
+
service.handleUpgrade({ method: msg.method, url: msg.url, headers: msg.headers }, socket, Buffer.from(msg.head, 'base64'));
|
|
84
|
+
} else if (msg.t === 'call') call(msg);
|
|
85
|
+
else if (msg.t === 'answer') {
|
|
86
|
+
asks.get(msg.id)?.(msg.value);
|
|
87
|
+
asks.delete(msg.id);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// cck is gone, so no claude may outlive the board that started it.
|
|
92
|
+
process.on('disconnect', () => {
|
|
93
|
+
service?.shutdown();
|
|
94
|
+
process.exit(0);
|
|
95
|
+
});
|