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/lib/terminal.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// (net-guard upgradeVerdict), refusal when the server is exposed, and a per-launch
|
|
8
8
|
// token sent in the first message. The token is the only one that stops a
|
|
9
9
|
// non-browser local process.
|
|
10
|
+
//
|
|
11
|
+
// The service runs in its own process (lib/terminal-host.js): cck checks the first two
|
|
12
|
+
// gates and hands the upgraded socket over, so no keystroke or output byte waits on
|
|
13
|
+
// cck's main thread.
|
|
10
14
|
|
|
11
15
|
const crypto = require('node:crypto');
|
|
12
16
|
const fs = require('node:fs');
|
|
@@ -249,15 +253,43 @@ function refuse(socket, status, reason) {
|
|
|
249
253
|
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${reason}\n`);
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
function localUnavailableReason(config, net) {
|
|
257
|
+
if (!config.enabled) return 'disabled';
|
|
258
|
+
if (net.EXPOSED) return 'refused while listening on a non-loopback address';
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function refuseUpgrade(req, socket, reasonFor) {
|
|
263
|
+
let pathname;
|
|
264
|
+
try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
|
|
265
|
+
if (pathname !== WS_PATH) {
|
|
266
|
+
refuse(socket, '404 Not Found', 'no such endpoint');
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
const reason = reasonFor();
|
|
270
|
+
if (reason) refuse(socket, '403 Forbidden', reason);
|
|
271
|
+
return !!reason;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function clientConfigFor(config, available) {
|
|
275
|
+
return {
|
|
276
|
+
available,
|
|
277
|
+
fontFamily: config.fontFamily,
|
|
278
|
+
fontSize: config.fontSize,
|
|
279
|
+
scrollback: config.scrollback,
|
|
280
|
+
maxSessions: config.maxSessions,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
252
284
|
/**
|
|
253
285
|
* @param {object} o
|
|
254
286
|
* @param {ReturnType<typeof readTerminalConfig>} o.config
|
|
255
287
|
* @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
|
|
256
288
|
* @param {string} o.claudeDir
|
|
257
289
|
* @param {boolean} o.isDefaultDir
|
|
258
|
-
* @param {(id: string) => string|null} o.resolveCwd null = unknown session
|
|
290
|
+
* @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd null = unknown session
|
|
259
291
|
* @param {(id: string, exceptPid?: number) => boolean} o.isLiveElsewhere
|
|
260
|
-
* @param {(dir: string) => boolean} o.isAllowedFolder where a new session may start
|
|
292
|
+
* @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder where a new session may start
|
|
261
293
|
* @param {() => {pid: number|null, sessionId: string, cwd: string|null, startedAt: number}[]} o.liveSessions
|
|
262
294
|
* claude's live-session registry, read fresh
|
|
263
295
|
* @param {(cmd: string) => string|null} o.which
|
|
@@ -310,10 +342,7 @@ function createTerminalService(o) {
|
|
|
310
342
|
}
|
|
311
343
|
|
|
312
344
|
function unavailableReason() {
|
|
313
|
-
|
|
314
|
-
if (net.EXPOSED) return 'refused while listening on a non-loopback address';
|
|
315
|
-
if (!load()) return loadError;
|
|
316
|
-
return null;
|
|
345
|
+
return localUnavailableReason(config, net) || (load() ? null : loadError);
|
|
317
346
|
}
|
|
318
347
|
|
|
319
348
|
function changed() {
|
|
@@ -343,11 +372,11 @@ function createTerminalService(o) {
|
|
|
343
372
|
function restore() {
|
|
344
373
|
if (!restoreQueue.length || unavailableReason()) return;
|
|
345
374
|
console.log(`Restoring ${restoreQueue.length} terminal(s)`);
|
|
346
|
-
const next = () => {
|
|
375
|
+
const next = async () => {
|
|
347
376
|
while (restoreQueue.length && !shuttingDown) {
|
|
348
377
|
const id = restoreQueue.shift();
|
|
349
378
|
if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
|
|
350
|
-
const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
|
|
379
|
+
const r = await start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
|
|
351
380
|
if (!r.error) break;
|
|
352
381
|
console.log(`Could not restore terminal ${id}: ${r.error}`);
|
|
353
382
|
}
|
|
@@ -498,7 +527,8 @@ function createTerminalService(o) {
|
|
|
498
527
|
// Claude's registry entry keeps its pid and swaps its sessionId to the picked one; the entry
|
|
499
528
|
// is found by folder and start time, and the pick shows as an id with a transcript behind it.
|
|
500
529
|
function watchPick(s) {
|
|
501
|
-
s.pickTimer = setInterval(() => {
|
|
530
|
+
s.pickTimer = setInterval(async () => {
|
|
531
|
+
if (s.pickChecking) return;
|
|
502
532
|
const live = o.liveSessions();
|
|
503
533
|
const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
|
|
504
534
|
if (!entry) {
|
|
@@ -509,7 +539,11 @@ function createTerminalService(o) {
|
|
|
509
539
|
return;
|
|
510
540
|
}
|
|
511
541
|
const picked = entry.sessionId;
|
|
512
|
-
if (picked === s.id
|
|
542
|
+
if (picked === s.id) return;
|
|
543
|
+
s.pickChecking = true;
|
|
544
|
+
const known = await o.resolveCwd(picked);
|
|
545
|
+
s.pickChecking = false;
|
|
546
|
+
if (known === null || s.exited || s.mode !== 'pick') return;
|
|
513
547
|
clearInterval(s.pickTimer);
|
|
514
548
|
// Two claude processes on one session write the same transcript. When cck already runs it,
|
|
515
549
|
// the pick is ended and its viewers move to that terminal; the sockets leave first so no
|
|
@@ -562,7 +596,7 @@ function createTerminalService(o) {
|
|
|
562
596
|
});
|
|
563
597
|
}
|
|
564
598
|
|
|
565
|
-
function onHello(ws, msg) {
|
|
599
|
+
async function onHello(ws, msg) {
|
|
566
600
|
if (!msg || msg.t !== 'hello' || !tokenMatches(token, msg.token)) {
|
|
567
601
|
send(ws, { t: 'error', msg: 'The terminal token is out of date. Reload the hub window.' });
|
|
568
602
|
return ws.close(4001);
|
|
@@ -584,36 +618,43 @@ function createTerminalService(o) {
|
|
|
584
618
|
send(ws, { t: 'live' });
|
|
585
619
|
return ws.close(1000);
|
|
586
620
|
}
|
|
587
|
-
const r = start(id, mode, msg, cols, rows);
|
|
621
|
+
const r = await start(id, mode, msg, cols, rows);
|
|
622
|
+
if (r.running) return attach(ws, r.running, true);
|
|
588
623
|
if (r.error) {
|
|
589
624
|
send(ws, { t: 'error', msg: r.error });
|
|
590
625
|
return ws.close(r.code);
|
|
591
626
|
}
|
|
627
|
+
// The viewer left while the PTY started; it runs headless until the next attach.
|
|
628
|
+
if (ws.readyState !== 1) return;
|
|
592
629
|
attach(ws, r.session, false);
|
|
593
630
|
}
|
|
594
631
|
|
|
595
632
|
// The admission rules shared by the socket hello and startNew. A refusal carries both a
|
|
596
633
|
// WebSocket close code and an HTTP status, so each caller maps it its own way.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
634
|
+
// resolveCwd and isAllowedFolder may answer later, so the limits are checked again after them.
|
|
635
|
+
async function start(id, mode, msg, cols, rows, extraEnv) {
|
|
636
|
+
const full = { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
|
|
637
|
+
if (sessions.size >= config.maxSessions) return full;
|
|
638
|
+
let spec = null;
|
|
639
|
+
if (mode === 'new') {
|
|
640
|
+
spec = parseNewSpec(msg);
|
|
641
|
+
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
600
642
|
}
|
|
601
|
-
const known = o.resolveCwd(id);
|
|
643
|
+
const known = await o.resolveCwd(id);
|
|
602
644
|
let cwd = known;
|
|
603
|
-
let spec = null;
|
|
604
645
|
if (mode === 'new' || mode === 'pick') {
|
|
605
|
-
if (mode === 'new') {
|
|
606
|
-
spec = parseNewSpec(msg);
|
|
607
|
-
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
608
|
-
}
|
|
609
646
|
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
610
|
-
if (typeof msg.cwd !== 'string' || !o.isAllowedFolder(msg.cwd)) {
|
|
647
|
+
if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
|
|
611
648
|
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
612
649
|
}
|
|
613
650
|
cwd = msg.cwd;
|
|
614
651
|
} else if (mode !== 'shell' && known === null) {
|
|
615
652
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
616
653
|
}
|
|
654
|
+
if (shuttingDown) return { code: 1001, status: 503, error: 'the terminal is shutting down' };
|
|
655
|
+
const running = sessions.get(id);
|
|
656
|
+
if (running && !running.ended) return { running, code: 4006, status: 409, error: 'session id already in use' };
|
|
657
|
+
if (sessions.size >= config.maxSessions) return full;
|
|
617
658
|
try {
|
|
618
659
|
return { session: spawnSession(id, mode === 'auto' ? 'resume' : mode, cwd, cols, rows, spec, extraEnv) };
|
|
619
660
|
} catch (e) {
|
|
@@ -622,31 +663,24 @@ function createTerminalService(o) {
|
|
|
622
663
|
}
|
|
623
664
|
|
|
624
665
|
function handleUpgrade(req, socket, head) {
|
|
625
|
-
|
|
626
|
-
try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
|
|
627
|
-
if (pathname !== WS_PATH) return refuse(socket, '404 Not Found', 'no such endpoint');
|
|
628
|
-
const reason = unavailableReason() || net.upgradeVerdict(req);
|
|
629
|
-
if (reason) return refuse(socket, '403 Forbidden', reason);
|
|
666
|
+
if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
|
|
630
667
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
631
668
|
const timer = setTimeout(() => ws.close(4001), HELLO_TIMEOUT_MS);
|
|
632
669
|
ws.once('message', (raw, isBinary) => {
|
|
633
670
|
clearTimeout(timer);
|
|
634
671
|
let msg = null;
|
|
635
672
|
if (!isBinary) { try { msg = JSON.parse(raw.toString()); } catch { /* treated as bad hello */ } }
|
|
636
|
-
onHello(ws, msg)
|
|
673
|
+
onHello(ws, msg).catch((e) => {
|
|
674
|
+
send(ws, { t: 'error', msg: `failed to start the terminal: ${e.message}` });
|
|
675
|
+
ws.close(1011);
|
|
676
|
+
});
|
|
637
677
|
});
|
|
638
678
|
ws.on('error', () => {});
|
|
639
679
|
});
|
|
640
680
|
}
|
|
641
681
|
|
|
642
682
|
function clientConfig() {
|
|
643
|
-
return
|
|
644
|
-
available: !unavailableReason(),
|
|
645
|
-
fontFamily: config.fontFamily,
|
|
646
|
-
fontSize: config.fontSize,
|
|
647
|
-
scrollback: config.scrollback,
|
|
648
|
-
maxSessions: config.maxSessions,
|
|
649
|
-
};
|
|
683
|
+
return clientConfigFor(config, !unavailableReason());
|
|
650
684
|
}
|
|
651
685
|
|
|
652
686
|
// kill() returns before the process is gone, so an ended session leaves list() at once.
|
|
@@ -693,10 +727,11 @@ function createTerminalService(o) {
|
|
|
693
727
|
|
|
694
728
|
// A 'new' session for a caller with no socket. The PTY runs headless until a browser
|
|
695
729
|
// attaches, and a later attach resizes it.
|
|
696
|
-
function startNew(msg, extraEnv) {
|
|
730
|
+
async function startNew(msg, extraEnv) {
|
|
697
731
|
const reason = unavailableReason();
|
|
698
732
|
if (reason) return { status: 403, error: reason };
|
|
699
|
-
|
|
733
|
+
if (msg.id != null && !(typeof msg.id === 'string' && UUID_RE.test(msg.id))) return { status: 400, error: 'invalid session id' };
|
|
734
|
+
const r = await start(msg.id || crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
|
|
700
735
|
return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
|
|
701
736
|
}
|
|
702
737
|
|
|
@@ -724,4 +759,4 @@ function createTerminalService(o) {
|
|
|
724
759
|
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
725
760
|
}
|
|
726
761
|
|
|
727
|
-
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
762
|
+
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -12,9 +12,10 @@ export function displayName(model: string) {
|
|
|
12
12
|
const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
|
|
13
13
|
|
|
14
14
|
// The board reads the statusLine's JSON shape, so the file keeps its field names.
|
|
15
|
-
export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
|
|
15
|
+
export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined, requestAt?: number) {
|
|
16
16
|
const { context } = m
|
|
17
17
|
return {
|
|
18
|
+
...(requestAt && { cache: { last_request_at: requestAt } }),
|
|
18
19
|
model: { id: model, display_name: displayName(model) },
|
|
19
20
|
cost: { total_cost_usd: m.cost?.usd ?? 0 },
|
|
20
21
|
context_window: {
|
|
@@ -43,6 +44,7 @@ function cckDir($: EngineInterface) {
|
|
|
43
44
|
|
|
44
45
|
let lastUsage: ModelUsage | undefined
|
|
45
46
|
let lastModel: string | undefined
|
|
47
|
+
let lastRequestAt: number | undefined
|
|
46
48
|
const lastWritten = new Map<string, string>()
|
|
47
49
|
|
|
48
50
|
async function write($: EngineInterface, m: Measure) {
|
|
@@ -51,7 +53,7 @@ async function write($: EngineInterface, m: Measure) {
|
|
|
51
53
|
lastModel ?? $.session.model(),
|
|
52
54
|
cckDir($),
|
|
53
55
|
])
|
|
54
|
-
const text = JSON.stringify(toStatus(model, m, lastUsage))
|
|
56
|
+
const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
|
|
55
57
|
const file = `${dir}/context-status/${sessionId}.json`
|
|
56
58
|
if (lastWritten.get(file) === text) return
|
|
57
59
|
await $.fs.write(file, text)
|
|
@@ -65,6 +67,7 @@ export const register: Register = on => {
|
|
|
65
67
|
const { model, ...usage } = result.usage
|
|
66
68
|
lastUsage = usage
|
|
67
69
|
lastModel = model
|
|
70
|
+
lastRequestAt = Date.now()
|
|
68
71
|
await write($, await $.session.usage())
|
|
69
72
|
}
|
|
70
73
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { On, SessionMeasureInput } from 'claude-code'
|
|
2
2
|
import { expect, test } from 'claude-code/testing'
|
|
3
|
-
import { displayName } from '../hooks/context'
|
|
3
|
+
import { displayName, toStatus } from '../hooks/context'
|
|
4
4
|
|
|
5
5
|
const MEASURE: SessionMeasureInput = {
|
|
6
6
|
context: { tokens: 129_174, window: 1_000_000, percent: 13 },
|
|
@@ -45,6 +45,19 @@ test('session.measure writes the statusLine shape the board reads', async ($, on
|
|
|
45
45
|
expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
test('session.measure writes no cache entry before the first request', async ($, on) => {
|
|
49
|
+
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-4')
|
|
50
|
+
|
|
51
|
+
await $.session.measure(MEASURE)
|
|
52
|
+
|
|
53
|
+
expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('toStatus adds the last request time', async () => {
|
|
57
|
+
const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
|
|
58
|
+
expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
|
|
59
|
+
})
|
|
60
|
+
|
|
48
61
|
test('skips a write when the status has not changed', async ($, on) => {
|
|
49
62
|
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
|
|
50
63
|
|
package/public/app.js
CHANGED
|
@@ -8931,6 +8931,73 @@ function formatCost(usd) {
|
|
|
8931
8931
|
return `$${usd.toFixed(2)}`;
|
|
8932
8932
|
}
|
|
8933
8933
|
|
|
8934
|
+
const CACHE_TTL = { '5m': { ms: 5 * 60000, write: 1.25 }, '1h': { ms: 60 * 60000, write: 2 } };
|
|
8935
|
+
// List input price, $ per million tokens. Most specific prefix first.
|
|
8936
|
+
const INPUT_PRICE_PER_MTOK = [
|
|
8937
|
+
['claude-fable-5', 10],
|
|
8938
|
+
['claude-mythos-5', 10],
|
|
8939
|
+
['claude-opus-5-5', 4],
|
|
8940
|
+
['claude-opus-5', 5],
|
|
8941
|
+
['claude-opus-4', 5],
|
|
8942
|
+
['claude-sonnet-5', 2],
|
|
8943
|
+
['claude-sonnet-4', 3],
|
|
8944
|
+
['claude-haiku-4', 1],
|
|
8945
|
+
];
|
|
8946
|
+
|
|
8947
|
+
// Same token count Claude Code prices in its own estimated_cache_write_usd.
|
|
8948
|
+
function cacheRewrite(usage, modelId, writeRate) {
|
|
8949
|
+
const price = INPUT_PRICE_PER_MTOK.find(([prefix]) => modelId?.startsWith(prefix))?.[1];
|
|
8950
|
+
const tokens =
|
|
8951
|
+
(usage.input_tokens || 0) +
|
|
8952
|
+
(usage.cache_read_input_tokens || 0) +
|
|
8953
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
8954
|
+
(usage.output_tokens || 0);
|
|
8955
|
+
if (!price || !tokens) return null;
|
|
8956
|
+
return { tokens, usd: (tokens / 1e6) * price * writeRate };
|
|
8957
|
+
}
|
|
8958
|
+
|
|
8959
|
+
function formatCacheLeft(expiresAt, expiredText) {
|
|
8960
|
+
const left = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
|
8961
|
+
if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
|
|
8962
|
+
const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
8963
|
+
const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
|
|
8964
|
+
return { text, color };
|
|
8965
|
+
}
|
|
8966
|
+
|
|
8967
|
+
let cacheTimerInterval = null;
|
|
8968
|
+
|
|
8969
|
+
function tickCacheTimers() {
|
|
8970
|
+
const els = document.querySelectorAll('.cache-timer');
|
|
8971
|
+
if (!els.length) {
|
|
8972
|
+
clearInterval(cacheTimerInterval);
|
|
8973
|
+
cacheTimerInterval = null;
|
|
8974
|
+
return;
|
|
8975
|
+
}
|
|
8976
|
+
if (!isOnScreen()) return;
|
|
8977
|
+
for (const el of els) {
|
|
8978
|
+
const { text, color } = formatCacheLeft(Number(el.dataset.expires), el.dataset.expiredText);
|
|
8979
|
+
if (el.textContent === text) continue;
|
|
8980
|
+
el.textContent = text;
|
|
8981
|
+
el.style.color = color;
|
|
8982
|
+
}
|
|
8983
|
+
}
|
|
8984
|
+
|
|
8985
|
+
function renderCacheTimer(lastRequestAt, ttl, usage, modelId) {
|
|
8986
|
+
const cache = CACHE_TTL[ttl];
|
|
8987
|
+
if (!lastRequestAt || !cache) return '';
|
|
8988
|
+
const expiresAt = lastRequestAt + cache.ms;
|
|
8989
|
+
const rewrite = cacheRewrite(usage, modelId, cache.write);
|
|
8990
|
+
const expiredText = rewrite ? `expired · ~${formatCost(rewrite.usd)}` : 'expired';
|
|
8991
|
+
const { text, color } = formatCacheLeft(expiresAt, expiredText);
|
|
8992
|
+
cacheTimerInterval ??= setInterval(tickCacheTimers, 1000);
|
|
8993
|
+
const at = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
8994
|
+
const cost = rewrite
|
|
8995
|
+
? ` It writes ${formatTokens(rewrite.tokens / 1000)} tokens again, about ${formatCost(rewrite.usd)} at list price.`
|
|
8996
|
+
: '';
|
|
8997
|
+
const title = `Prompt cache, ${ttl} TTL. Expires at ${at}; the next request after that writes it again.${cost}`;
|
|
8998
|
+
return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${expiresAt}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
|
|
8999
|
+
}
|
|
9000
|
+
|
|
8934
9001
|
function renderContextDetail(session, { tokens = true } = {}) {
|
|
8935
9002
|
const ctx = getCtx(session);
|
|
8936
9003
|
if (!ctx) return '';
|
|
@@ -8968,6 +9035,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
|
|
|
8968
9035
|
<div class="detail-context-stats">
|
|
8969
9036
|
${tokenRows}
|
|
8970
9037
|
<div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
|
|
9038
|
+
${renderCacheTimer(raw.cache?.last_request_at, session.cacheTtl, usage, raw.model?.id)}
|
|
8971
9039
|
${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
|
|
8972
9040
|
${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
|
|
8973
9041
|
${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
|
|
@@ -12455,14 +12523,16 @@ function formatTerminalProc(s) {
|
|
|
12455
12523
|
async function pollTerminalProcStats() {
|
|
12456
12524
|
if (!isOnScreen()) return;
|
|
12457
12525
|
let cck;
|
|
12526
|
+
let host;
|
|
12458
12527
|
try {
|
|
12459
12528
|
const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
|
|
12460
12529
|
if (!res.ok) return;
|
|
12461
|
-
({ cck, terminals: terminalProcStats } = await res.json());
|
|
12530
|
+
({ cck, host, terminals: terminalProcStats } = await res.json());
|
|
12462
12531
|
} catch (_) {
|
|
12463
12532
|
return;
|
|
12464
12533
|
}
|
|
12465
|
-
|
|
12534
|
+
const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
|
|
12535
|
+
document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
|
|
12466
12536
|
for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
|
|
12467
12537
|
el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
|
|
12468
12538
|
}
|
|
@@ -12568,7 +12638,8 @@ const newSpecs = new Map();
|
|
|
12568
12638
|
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
12569
12639
|
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
12570
12640
|
const dispatchSpecs = new Map();
|
|
12571
|
-
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '',
|
|
12641
|
+
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
|
|
12642
|
+
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
12572
12643
|
|
|
12573
12644
|
const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
|
|
12574
12645
|
const PLACEHOLDER_HINTS = {
|
|
@@ -12627,9 +12698,13 @@ async function loadDispatches() {
|
|
|
12627
12698
|
|
|
12628
12699
|
async function onDispatchUpdate() {
|
|
12629
12700
|
await loadDispatches();
|
|
12701
|
+
refreshPlaceholders();
|
|
12702
|
+
fetchSessions(false).catch(() => {});
|
|
12703
|
+
}
|
|
12704
|
+
|
|
12705
|
+
function refreshPlaceholders() {
|
|
12630
12706
|
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
12631
12707
|
renderSessions();
|
|
12632
|
-
fetchSessions(false).catch(() => {});
|
|
12633
12708
|
}
|
|
12634
12709
|
|
|
12635
12710
|
// With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
|
|
@@ -12708,6 +12783,7 @@ function openNewSession(folder, resume = false) {
|
|
|
12708
12783
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
|
|
12709
12784
|
document.getElementById('ns-wt').checked = false;
|
|
12710
12785
|
document.getElementById('ns-model').value = '';
|
|
12786
|
+
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
12711
12787
|
setNewSessionError('');
|
|
12712
12788
|
const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
|
|
12713
12789
|
setNewSessionFolder(folder || current || '');
|
|
@@ -12841,36 +12917,44 @@ function renderNewSessionForm() {
|
|
|
12841
12917
|
const problem = newSessionProblem(v);
|
|
12842
12918
|
setNewSessionError(problem);
|
|
12843
12919
|
const start = document.getElementById('ns-start');
|
|
12844
|
-
start.disabled = !v.cwd || !!problem || ns.
|
|
12920
|
+
start.disabled = !v.cwd || !!problem || ns.busy;
|
|
12845
12921
|
const verb = ns.resume ? 'Resume' : 'Start';
|
|
12846
12922
|
start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
|
|
12847
12923
|
}
|
|
12848
12924
|
|
|
12925
|
+
// Resolves to the response body, or to { error } with the message for the dialog.
|
|
12926
|
+
async function newSessionPost(url, body, failure) {
|
|
12927
|
+
try {
|
|
12928
|
+
const res = await terminalFetch(url, 'POST', body);
|
|
12929
|
+
const out = await res.json().catch(() => ({}));
|
|
12930
|
+
if (res.ok) return out;
|
|
12931
|
+
return { error: res.status === 401 ? STALE_TOKEN_MSG : out.error || `${failure} (${res.status})` };
|
|
12932
|
+
} catch (e) {
|
|
12933
|
+
return { error: e.message };
|
|
12934
|
+
}
|
|
12935
|
+
}
|
|
12936
|
+
|
|
12937
|
+
function setNewSessionBusy(on) {
|
|
12938
|
+
ns.busy = on;
|
|
12939
|
+
renderNewSessionForm();
|
|
12940
|
+
}
|
|
12941
|
+
|
|
12849
12942
|
async function browseNewSessionFolder() {
|
|
12850
|
-
if (ns.
|
|
12851
|
-
ns.browsing = true;
|
|
12943
|
+
if (ns.busy) return;
|
|
12852
12944
|
closeFolderList();
|
|
12853
12945
|
const btn = document.getElementById('ns-browse');
|
|
12854
12946
|
btn.textContent = 'Opening…';
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
|
|
12862
|
-
else if (body.path) {
|
|
12863
|
-
ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
|
|
12864
|
-
setNewSessionFolder(body.path);
|
|
12865
|
-
}
|
|
12866
|
-
} catch (e) {
|
|
12867
|
-
setNewSessionError(e.message);
|
|
12868
|
-
} finally {
|
|
12869
|
-
ns.browsing = false;
|
|
12870
|
-
btn.textContent = 'Browse…';
|
|
12871
|
-
renderNewSessionForm();
|
|
12872
|
-
document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
|
|
12947
|
+
setNewSessionBusy(true);
|
|
12948
|
+
const start = ns.folder || ns.picked[0] || ns.projects[0];
|
|
12949
|
+
const r = await newSessionPost('/api/terminal/pick-folder', start ? { start } : null, 'Folder dialog failed');
|
|
12950
|
+
if (r.path) {
|
|
12951
|
+
ns.picked = [r.path, ...ns.picked.filter((p) => p !== r.path)];
|
|
12952
|
+
setNewSessionFolder(r.path);
|
|
12873
12953
|
}
|
|
12954
|
+
btn.textContent = 'Browse…';
|
|
12955
|
+
setNewSessionBusy(false);
|
|
12956
|
+
if (r.error) setNewSessionError(r.error);
|
|
12957
|
+
document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
|
|
12874
12958
|
}
|
|
12875
12959
|
|
|
12876
12960
|
function fieldAfterFolder() {
|
|
@@ -12879,15 +12963,36 @@ function fieldAfterFolder() {
|
|
|
12879
12963
|
|
|
12880
12964
|
function startNewSession() {
|
|
12881
12965
|
const v = newSessionValues();
|
|
12882
|
-
if (!v.cwd || newSessionProblem(v) || ns.
|
|
12966
|
+
if (!v.cwd || newSessionProblem(v) || ns.busy) return;
|
|
12883
12967
|
const id = crypto.randomUUID();
|
|
12884
12968
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
12885
|
-
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
12886
12969
|
setTerminalMode(id, true);
|
|
12970
|
+
if (!ns.resume && document.getElementById('ns-stay').checked) return startNewSessionInBackground(id, v);
|
|
12971
|
+
sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
|
|
12887
12972
|
closeNewSession();
|
|
12888
12973
|
fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
|
|
12889
12974
|
}
|
|
12890
12975
|
|
|
12976
|
+
async function startNewSessionInBackground(id, v) {
|
|
12977
|
+
refreshPlaceholders();
|
|
12978
|
+
setNewSessionBusy(true);
|
|
12979
|
+
const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
|
|
12980
|
+
setNewSessionBusy(false);
|
|
12981
|
+
if (r.error) {
|
|
12982
|
+
dropPlaceholder(id);
|
|
12983
|
+
return setNewSessionError(r.error);
|
|
12984
|
+
}
|
|
12985
|
+
// A server started before the page's code changed ignores the id and picks its own.
|
|
12986
|
+
if (r.session !== id) {
|
|
12987
|
+
const spec = newSpecs.get(id);
|
|
12988
|
+
forgetPlaceholder(id);
|
|
12989
|
+
newSpecs.set(r.session, spec);
|
|
12990
|
+
setTerminalMode(r.session, true);
|
|
12991
|
+
refreshPlaceholders();
|
|
12992
|
+
}
|
|
12993
|
+
closeNewSession();
|
|
12994
|
+
}
|
|
12995
|
+
|
|
12891
12996
|
const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
12892
12997
|
const promptVoice = { rec: null };
|
|
12893
12998
|
|
|
@@ -12963,6 +13068,9 @@ function initNewSession() {
|
|
|
12963
13068
|
renderNewSessionForm();
|
|
12964
13069
|
if (e.target.checked) document.getElementById('ns-wt-name').focus();
|
|
12965
13070
|
});
|
|
13071
|
+
document
|
|
13072
|
+
.getElementById('ns-stay')
|
|
13073
|
+
.addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
|
|
12966
13074
|
for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
|
|
12967
13075
|
document.getElementById(id).addEventListener('input', renderNewSessionForm);
|
|
12968
13076
|
}
|
package/public/index.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
</div>
|
|
53
53
|
<h2>Server Not Running</h2>
|
|
54
54
|
<p>The dashboard needs the Claude Code Kanban server to stream live task data. Start it with:</p>
|
|
55
|
-
<code>
|
|
55
|
+
<code>claude-code-kanban</code>
|
|
56
56
|
<button class="btn btn-primary" onclick="location.reload()">Retry Connection</button>
|
|
57
57
|
<div class="offline-status" id="offline-status" aria-live="polite"></div>
|
|
58
58
|
</div>
|
|
@@ -818,6 +818,7 @@
|
|
|
818
818
|
<div id="ns-error" class="ns-error" hidden></div>
|
|
819
819
|
</div>
|
|
820
820
|
<div class="modal-footer ns-footer">
|
|
821
|
+
<label class="ns-check ns-stay" title="Start in the background and keep the current view"><input type="checkbox" id="ns-stay"> Stay here</label>
|
|
821
822
|
<button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
|
|
822
823
|
<button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
|
|
823
824
|
</div>
|
package/public/style.css
CHANGED
|
@@ -6861,6 +6861,17 @@ select.form-input option:checked {
|
|
|
6861
6861
|
|
|
6862
6862
|
.ns-footer {
|
|
6863
6863
|
align-items: center;
|
|
6864
|
+
margin-top: 16px;
|
|
6865
|
+
border-top: 1px solid var(--border);
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
.ns-stay {
|
|
6869
|
+
margin-right: auto;
|
|
6870
|
+
color: var(--text-tertiary);
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
.new-session-modal.ns-resume .ns-stay {
|
|
6874
|
+
visibility: hidden;
|
|
6864
6875
|
}
|
|
6865
6876
|
|
|
6866
6877
|
.ns-footer .btn-primary:disabled {
|