claude-code-kanban 6.0.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 +21 -11
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/session-cache.js +1 -1
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +73 -38
- 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/tests/context.test.ts +14 -1
- package/public/app.js +135 -27
- package/public/index.html +2 -1
- package/public/style.css +11 -0
- package/server.js +60 -65
package/README.md
CHANGED
|
@@ -21,25 +21,35 @@ Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https:/
|
|
|
21
21
|
|
|
22
22
|
You need Node.js 20 or later and the `claude` CLI.
|
|
23
23
|
|
|
24
|
-
### 1. Install the
|
|
24
|
+
### 1. Install the command
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
npm install -g claude-code-kanban
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To update, run the same command again.
|
|
31
|
+
|
|
32
|
+
To try it without an install, run `npx claude-code-kanban --open`. npx keeps a copy of each version in its cache and can run an old copy, so use the global install to keep the board and to update it.
|
|
33
|
+
|
|
34
|
+
### 2. Install the integration (one time)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude-code-kanban --install
|
|
28
38
|
```
|
|
29
39
|
|
|
30
40
|
The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
|
|
31
41
|
|
|
32
|
-
To remove it, run `
|
|
42
|
+
To remove it, run `claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
|
|
33
43
|
|
|
34
|
-
###
|
|
44
|
+
### 3. Start the board
|
|
35
45
|
|
|
36
46
|
```bash
|
|
37
|
-
|
|
47
|
+
claude-code-kanban --open
|
|
38
48
|
```
|
|
39
49
|
|
|
40
|
-
The board runs at `http://localhost:3541`.
|
|
50
|
+
The board runs at `http://localhost:3541`.
|
|
41
51
|
|
|
42
|
-
###
|
|
52
|
+
### 4. Use Claude Code as usual
|
|
43
53
|
|
|
44
54
|
Run `claude` in any project. You do not configure anything per project. Claude Code writes task files and transcripts to the config dir, and the board watches them and sends each change to the browser.
|
|
45
55
|
|
|
@@ -107,10 +117,10 @@ Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.
|
|
|
107
117
|
## Configuration
|
|
108
118
|
|
|
109
119
|
```bash
|
|
110
|
-
PORT=8080
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
EDITOR="code -w"
|
|
120
|
+
PORT=8080 claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
|
|
121
|
+
claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
|
|
122
|
+
claude-code-kanban --enable-terminal # Turn on the embedded terminal
|
|
123
|
+
EDITOR="code -w" claude-code-kanban # Command for Open in editor (default: code)
|
|
114
124
|
```
|
|
115
125
|
|
|
116
126
|
- The server listens on `127.0.0.1` only and has no authentication. To reach it from another machine, use `--host` and `--allowed-hosts`, and do it only on a network you trust.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// Claude Code's live-session registry: one <pid>.json per interactive claude.
|
|
7
|
+
function readLiveSessions(sessionsDir) {
|
|
8
|
+
const sessions = [];
|
|
9
|
+
let files;
|
|
10
|
+
try {
|
|
11
|
+
files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
|
|
12
|
+
} catch {
|
|
13
|
+
return sessions;
|
|
14
|
+
}
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
try {
|
|
17
|
+
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
|
|
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 });
|
|
20
|
+
}
|
|
21
|
+
} catch { /* skip invalid */ }
|
|
22
|
+
}
|
|
23
|
+
return sessions;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// EPERM means the process exists but belongs to someone else.
|
|
27
|
+
function isPidAlive(pid) {
|
|
28
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A registry file outlives a crashed claude, so the pid is probed rather than trusted.
|
|
32
|
+
function isSessionLive(sessions, sessionId, exceptPid = null) {
|
|
33
|
+
return sessions.some((s) => {
|
|
34
|
+
if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
|
|
35
|
+
return isPidAlive(s.pid);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { readLiveSessions, isPidAlive, isSessionLive };
|
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/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
|
|
|
@@ -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
|
+
});
|