claude-code-kanban 6.1.1 → 7.0.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 +3 -2
- package/cli.js +206 -168
- package/lib/frame-policy.js +79 -0
- package/lib/http-error.js +6 -0
- package/lib/linked-docs.js +5 -7
- package/lib/one-line.js +6 -0
- package/lib/panes.js +174 -0
- package/lib/parsers.js +150 -41
- package/lib/rate-limits.js +21 -0
- package/lib/retention.js +67 -19
- package/lib/scratchpad-dir.js +41 -0
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -4
- package/lib/terminal.js +46 -6
- package/lib/worktrees.js +11 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +3 -2
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +18 -19
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +31 -21
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +1 -1
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +33 -2
- package/public/app.js +1023 -170
- package/public/index.html +37 -0
- package/public/link-url.js +6 -3
- package/public/style.css +487 -41
- package/public/tree-highlight.js +74 -0
- package/server.js +194 -84
- package/skill-guides/dispatch.md +1 -1
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 = 5;
|
|
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
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
4
|
// grows one permanent entry per session id ever named in a request path.
|
|
5
5
|
|
|
6
|
+
const { oneLine } = require('./one-line');
|
|
7
|
+
|
|
6
8
|
// Tells a live session that the board moved one of its tasks. The postman monitor
|
|
7
9
|
// (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
|
|
8
10
|
// each line, which Claude Code delivers into that session as a task notification.
|
|
@@ -24,10 +26,7 @@ const sessionEventBuckets = new Map();
|
|
|
24
26
|
//
|
|
25
27
|
// One cap for the whole line rather than one per field, because the description comes
|
|
26
28
|
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
27
|
-
|
|
28
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
29
|
-
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
30
|
-
}
|
|
29
|
+
const sanitizeEventLine = (line) => oneLine(line, 1500);
|
|
31
30
|
|
|
32
31
|
// Lines are plain sentences so they still read right once the skill that explains them has
|
|
33
32
|
// left the agent's context. EVENT_PREFIX marks the board as the sender.
|
package/lib/terminal.js
CHANGED
|
@@ -17,6 +17,7 @@ const fs = require('node:fs');
|
|
|
17
17
|
const os = require('node:os');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const { isContained } = require('./contain');
|
|
20
|
+
const { isPidAlive } = require('./live-sessions');
|
|
20
21
|
const priority = require('./priority');
|
|
21
22
|
|
|
22
23
|
const WS_PATH = '/api/terminal/ws';
|
|
@@ -57,6 +58,8 @@ const RESTORE_GAP_MS = 2000;
|
|
|
57
58
|
const SAVE_DELAY_MS = 1000;
|
|
58
59
|
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
59
60
|
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
61
|
+
// Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
|
|
62
|
+
const CLAUDE_EXIT_POLL_MS = 5000;
|
|
60
63
|
// The size of a PTY no browser has attached to yet; the first attach resizes it.
|
|
61
64
|
const HEADLESS_COLS = 120;
|
|
62
65
|
const HEADLESS_ROWS = 32;
|
|
@@ -315,6 +318,10 @@ function createTerminalService(o) {
|
|
|
315
318
|
let lastSaved = null;
|
|
316
319
|
const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
|
|
317
320
|
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
321
|
+
const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
|
|
322
|
+
const pidAlive = o.isPidAlive || isPidAlive;
|
|
323
|
+
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
324
|
+
exitTimer.unref();
|
|
318
325
|
// Read before any terminal starts, so the first save keeps the ids still waiting to restore.
|
|
319
326
|
const saved = config.restore ? o.load?.()?.sessions : null;
|
|
320
327
|
const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
|
|
@@ -359,7 +366,7 @@ function createTerminalService(o) {
|
|
|
359
366
|
function saveState() {
|
|
360
367
|
saveTimer = null;
|
|
361
368
|
const ids = new Set(restoreQueue);
|
|
362
|
-
for (const s of sessions.values()) if (!s.ended && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
|
|
369
|
+
for (const s of sessions.values()) if (!s.ended && !s.claudeExitedAt && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
|
|
363
370
|
const data = { sessions: [...ids] };
|
|
364
371
|
const json = JSON.stringify(data);
|
|
365
372
|
if (json === lastSaved) return;
|
|
@@ -435,11 +442,12 @@ function createTerminalService(o) {
|
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
// The process a viewer types into: claude, found through its registry entry, or the shell.
|
|
438
|
-
function inputPid(s) {
|
|
445
|
+
function inputPid(s, registry) {
|
|
439
446
|
if (s.mode === 'shell') return s.pty.pid;
|
|
447
|
+
if (s.claudeExitedAt) return null;
|
|
440
448
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
441
|
-
const live = o.liveSessions();
|
|
442
|
-
const own = live
|
|
449
|
+
const live = registry || o.liveSessions();
|
|
450
|
+
const own = ownEntry(live, s, s.startedAt);
|
|
443
451
|
if (own || s.mode !== 'fork') {
|
|
444
452
|
s.claudePid = own?.pid;
|
|
445
453
|
return s.claudePid;
|
|
@@ -449,6 +457,36 @@ function createTerminalService(o) {
|
|
|
449
457
|
return s.claudePid;
|
|
450
458
|
}
|
|
451
459
|
|
|
460
|
+
// Claude deletes its registry entry on exit, and a crash leaves one with a dead pid. A terminal
|
|
461
|
+
// with no pid yet is still starting. Matching by pid keeps a /clear or /resume, which rewrite
|
|
462
|
+
// the entry's sessionId, from reading as an exit.
|
|
463
|
+
function checkExits() {
|
|
464
|
+
const watched = [...sessions.values()].filter((s) => !s.exited && !s.ended && s.mode !== 'shell');
|
|
465
|
+
if (!watched.length) return;
|
|
466
|
+
const live = o.liveSessions();
|
|
467
|
+
let moved = false;
|
|
468
|
+
for (const s of watched) {
|
|
469
|
+
if (s.claudeExitedAt) {
|
|
470
|
+
const back = ownEntry(live, s, s.claudeExitedAt);
|
|
471
|
+
if (!back || !pidAlive(back.pid)) continue;
|
|
472
|
+
s.claudeExitedAt = null;
|
|
473
|
+
s.claudePid = back.pid;
|
|
474
|
+
moved = true;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const pid = inputPid(s, live);
|
|
478
|
+
if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
|
|
479
|
+
s.claudeExitedAt = Date.now();
|
|
480
|
+
s.claudePid = null;
|
|
481
|
+
moved = true;
|
|
482
|
+
}
|
|
483
|
+
if (moved) changed();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function ownEntry(live, s, since) {
|
|
487
|
+
return live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= since - PICK_START_SLACK_MS);
|
|
488
|
+
}
|
|
489
|
+
|
|
452
490
|
// claude writes its registry entry a moment after it starts, so the pid is polled for.
|
|
453
491
|
function syncBoost(s) {
|
|
454
492
|
const want = s.sockets.size > 0 && !s.exited;
|
|
@@ -699,9 +737,10 @@ function createTerminalService(o) {
|
|
|
699
737
|
|
|
700
738
|
function claudePids() {
|
|
701
739
|
const out = {};
|
|
740
|
+
const live = sessions.size ? o.liveSessions() : [];
|
|
702
741
|
for (const s of sessions.values()) {
|
|
703
742
|
if (s.ended || s.exited || s.mode === 'shell') continue;
|
|
704
|
-
const pid = inputPid(s);
|
|
743
|
+
const pid = inputPid(s, live);
|
|
705
744
|
if (pid) out[s.id] = pid;
|
|
706
745
|
}
|
|
707
746
|
return out;
|
|
@@ -741,7 +780,7 @@ function createTerminalService(o) {
|
|
|
741
780
|
// pending queued prompt owns the input box until it is sent.
|
|
742
781
|
function paste(id, text) {
|
|
743
782
|
const s = sessions.get(id);
|
|
744
|
-
if (!s || s.ended || s.exited || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
783
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
745
784
|
if (onTrustScreen(s)) return false;
|
|
746
785
|
s.pty.write(bracketed(text));
|
|
747
786
|
return true;
|
|
@@ -750,6 +789,7 @@ function createTerminalService(o) {
|
|
|
750
789
|
function shutdown() {
|
|
751
790
|
shuttingDown = true;
|
|
752
791
|
clearTimeout(saveTimer);
|
|
792
|
+
clearInterval(exitTimer);
|
|
753
793
|
for (const s of sessions.values()) {
|
|
754
794
|
try { s.pty.kill(); } catch { /* already gone */ }
|
|
755
795
|
}
|
package/lib/worktrees.js
CHANGED
|
@@ -10,6 +10,11 @@ const { encodeProjectDirName } = require('./claude-dir');
|
|
|
10
10
|
// main checkout is readable without spawning git. Path shape alone would not do: only some
|
|
11
11
|
// worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
|
|
12
12
|
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
13
|
+
// A submodule's worktree points into the superproject: `<super>/.git/modules/<sub>/worktrees/<name>`,
|
|
14
|
+
// nested as `<a>/modules/<b>`. The checkout is taken to sit at `<super>/<sub>`, which holds while
|
|
15
|
+
// the submodule's name is its path, the git default; reading `core.worktree` would cost a read.
|
|
16
|
+
const GITDIR_SUBMODULE_WORKTREE_RE =
|
|
17
|
+
/^gitdir:\s*(.*)[/\\]\.git[/\\]modules[/\\](.+?)[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
13
18
|
// Where `claude -w` puts a worktree. Used only once the `.git` file is gone, because Claude
|
|
14
19
|
// Code removes worktrees while their transcripts stay.
|
|
15
20
|
const CLAUDE_WORKTREE_RE = /^(.*)[/\\]\.claude[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
@@ -20,9 +25,13 @@ function readGitFile(dir) {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
function fromGitFile(dir, text) {
|
|
23
|
-
const
|
|
28
|
+
const pointer = text.trim();
|
|
29
|
+
const m = GITDIR_WORKTREE_RE.exec(pointer);
|
|
24
30
|
// Git writes the pointer with forward slashes on Windows; the project path uses the OS spelling.
|
|
25
|
-
|
|
31
|
+
if (m) return { repo: path.resolve(dir, m[1]), name: m[2] };
|
|
32
|
+
const sub = GITDIR_SUBMODULE_WORKTREE_RE.exec(pointer);
|
|
33
|
+
if (!sub) return null;
|
|
34
|
+
return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
function fromPathShape(dir) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.1",
|
|
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"
|
|
@@ -149,13 +149,14 @@ async function markStopped($: EngineInterface) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// The name map lets TeammateIdle, which names the teammate, find its agent id.
|
|
152
|
-
async function agentStarted($: EngineInterface, agentId: string, type: string, name?: string) {
|
|
152
|
+
async function agentStarted($: EngineInterface, agentId: string, type: string, model: string, name?: string) {
|
|
153
153
|
const dir = await sessionDir($)
|
|
154
154
|
const ts = new Date().toISOString()
|
|
155
155
|
await Promise.all([
|
|
156
156
|
appendLine($, `${dir}/${agentId}.jsonl`, {
|
|
157
157
|
agentId,
|
|
158
158
|
type,
|
|
159
|
+
model,
|
|
159
160
|
event: 'start',
|
|
160
161
|
status: 'active',
|
|
161
162
|
startedAt: ts,
|
|
@@ -248,7 +249,7 @@ export const register: Register = on => {
|
|
|
248
249
|
})
|
|
249
250
|
on('agent.spawn', async ($, e, next) => {
|
|
250
251
|
const result = await next(e)
|
|
251
|
-
if (result.agentId) await agentStarted($, result.agentId, e.subagentType, e.name)
|
|
252
|
+
if (result.agentId) await agentStarted($, result.agentId, e.subagentType, result.model, e.name)
|
|
252
253
|
return result
|
|
253
254
|
})
|
|
254
255
|
// An interrupted turn fires no Stop hook, so it does not mark the session unread either.
|
|
@@ -42,22 +42,17 @@ function cckDir($: EngineInterface) {
|
|
|
42
42
|
return cck
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let lastRequestAt: number | undefined
|
|
48
|
-
const lastWritten = new Map<string, string>()
|
|
45
|
+
// Keyed by session id: `/clear` keeps the process and changes the id.
|
|
46
|
+
const sessions = new Map<string, { req?: { usage: ModelUsage; model: string; at: number }; written?: string }>()
|
|
49
47
|
|
|
50
|
-
async function write($: EngineInterface, m: Measure) {
|
|
51
|
-
const [sessionId,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (lastWritten.get(file) === text) return
|
|
59
|
-
await $.fs.write(file, text)
|
|
60
|
-
lastWritten.set(file, text)
|
|
48
|
+
async function write($: EngineInterface, m: Measure, id?: string) {
|
|
49
|
+
const [sessionId, dir] = await Promise.all([id ?? $.session.id(), cckDir($)])
|
|
50
|
+
const s = sessions.get(sessionId) ?? {}
|
|
51
|
+
const model = s.req?.model ?? (await $.session.model())
|
|
52
|
+
const text = JSON.stringify(toStatus(model, m, s.req?.usage, s.req?.at))
|
|
53
|
+
if (s.written === text) return
|
|
54
|
+
await $.fs.write(`${dir}/context-status/${sessionId}.json`, text)
|
|
55
|
+
sessions.set(sessionId, { ...s, written: text })
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
export const register: Register = on => {
|
|
@@ -65,10 +60,9 @@ export const register: Register = on => {
|
|
|
65
60
|
const result = yield* next(e)
|
|
66
61
|
if (e.agentId === undefined && result.usage) {
|
|
67
62
|
const { model, ...usage } = result.usage
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await write($, await $.session.usage())
|
|
63
|
+
const sessionId = await $.session.id()
|
|
64
|
+
sessions.set(sessionId, { ...sessions.get(sessionId), req: { usage, model, at: Date.now() } })
|
|
65
|
+
await write($, await $.session.usage(), sessionId)
|
|
72
66
|
}
|
|
73
67
|
return result
|
|
74
68
|
})
|
|
@@ -77,4 +71,9 @@ export const register: Register = on => {
|
|
|
77
71
|
await write($, e)
|
|
78
72
|
return next(e)
|
|
79
73
|
})
|
|
74
|
+
|
|
75
|
+
on('session.end', async ($, e, next) => {
|
|
76
|
+
sessions.delete(e.sessionId)
|
|
77
|
+
return next(e)
|
|
78
|
+
})
|
|
80
79
|
}
|
|
@@ -1,35 +1,45 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
|
-
description:
|
|
4
|
-
argument-hint: '[
|
|
3
|
+
description: Kanban board CLI — show the user a file or page, link a doc or PR, add a pane, open or pin a session, find a session's transcript, list tasks.
|
|
4
|
+
argument-hint: '[doc|pane|session|task|project|dispatch] <subcommand> [target]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban Skill
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The skill is a thin wrapper over the `claude-code-kanban` CLI: the argument is a command and its subcommand, such as `doc link plan.md` or `session pin`. Run the bare binary, or `npx claude-code-kanban` when it is off PATH or the user asks for npx.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$CLAUDE_CODE_SESSION_ID`, so pass the flag only for another session.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## Commands
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
claude-code-kanban help # every command
|
|
17
|
-
claude-code-kanban help <command> # its subcommands
|
|
18
|
-
claude-code-kanban help <command> <subcommand> # flags and examples
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
| Argument | Command |
|
|
15
|
+
| Command | Subcommands |
|
|
22
16
|
|---|---|
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
|
|
17
|
+
| `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
|
|
18
|
+
| `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
|
|
19
|
+
| `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` |
|
|
20
|
+
| `task` | `list` for a session, a project or the whole board |
|
|
21
|
+
| `project` | `list` |
|
|
22
|
+
| `dispatch` | `start`, `list` — run `/claude-code-kanban:dispatch` instead |
|
|
23
|
+
|
|
24
|
+
## Run a command
|
|
25
|
+
|
|
26
|
+
1. Read `claude-code-kanban help <command> <subcommand>` for the flags. It matches the installed binary, so its flags are the ones that work.
|
|
27
|
+
2. Run the command and report its output line to the user.
|
|
28
|
+
|
|
29
|
+
With no argument, run nothing on the board: show the user the table above.
|
|
30
|
+
|
|
31
|
+
## Keep the board still
|
|
32
|
+
|
|
33
|
+
The user works in other windows while you run, so pick the quiet command. `doc link` and `pane add` add an entry the user opens when ready. `doc preview` is the one command that opens a modal on the user's screen: use it when the user asks to see something now.
|
|
34
|
+
|
|
35
|
+
To show the user a page, a prototype or a collage of screenshots, write it as HTML and link or preview that file. The preview renders HTML as authored in a sandboxed iframe and inlines its local stylesheets, scripts and images, so the file alone is enough.
|
|
36
|
+
|
|
37
|
+
## Another session
|
|
38
|
+
|
|
39
|
+
Read its transcript. `session view <id>` and `session search <text> --json` print the path.
|
|
30
40
|
|
|
31
|
-
To
|
|
41
|
+
To have card moves arrive in this session as instructions, the user types `/claude-code-kanban:follow`.
|
|
32
42
|
|
|
33
43
|
## Troubleshooting
|
|
34
44
|
|
|
35
|
-
- **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If
|
|
45
|
+
- **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If it runs elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.
|
|
@@ -253,7 +253,7 @@ test('a subagent spawn then finish records start and stop', async ($, on) => {
|
|
|
253
253
|
await $.turn.complete(turn({ agentId: 'a1', answer: 'Task done with "quotes"' }))
|
|
254
254
|
|
|
255
255
|
const [start, stop] = lines(files[`${SESSION}/a1.jsonl`])
|
|
256
|
-
expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', event: 'start', status: 'active' })
|
|
256
|
+
expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', model: 'haiku', event: 'start', status: 'active' })
|
|
257
257
|
expect(stop).toMatchObject({ agentId: 'a1', event: 'stop', status: 'stopped', lastMessage: 'Task done with "quotes"' })
|
|
258
258
|
expect('type' in stop).toBe(false)
|
|
259
259
|
expect(files[`${SESSION}/_name-general-purpose.id`]).toBe('a1')
|
|
@@ -12,14 +12,14 @@ const MEASURE: SessionMeasureInput = {
|
|
|
12
12
|
changed: ['context', 'rateLimits', 'cost'],
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function engine(on: On, env: Record<string, string>, sessionId: string) {
|
|
15
|
+
function engine(on: On, env: Record<string, string>, sessionId: string | (() => string)) {
|
|
16
16
|
const writes: { path: string; text: string }[] = []
|
|
17
17
|
on('fs.write', (_$, e) => {
|
|
18
18
|
writes.push({ path: e.path.replaceAll('\\', '/'), text: e.text })
|
|
19
19
|
return { value: undefined }
|
|
20
20
|
})
|
|
21
21
|
on('env.get', (_$, e) => ({ value: env[e.name] }))
|
|
22
|
-
on('session.id', () => ({ value: sessionId }))
|
|
22
|
+
on('session.id', () => ({ value: typeof sessionId === 'function' ? sessionId() : sessionId }))
|
|
23
23
|
on('session.model', () => ({ value: 'claude-opus-5-5' }))
|
|
24
24
|
on('session.measure', (_$, e) => ({ changed: [...e.changed] }))
|
|
25
25
|
return writes
|
|
@@ -53,6 +53,37 @@ test('session.measure writes no cache entry before the first request', async ($,
|
|
|
53
53
|
expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
+
const USAGE = { input_tokens: 2, output_tokens: 100, cache_read_input_tokens: 5000, cache_creation_input_tokens: 300 }
|
|
57
|
+
|
|
58
|
+
test('after /clear the new session id does not get the old request', async ($, on) => {
|
|
59
|
+
let sid = 'sid-old'
|
|
60
|
+
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, () => sid)
|
|
61
|
+
on('session.usage', () => ({ value: MEASURE }))
|
|
62
|
+
on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
|
|
63
|
+
on('turn.step', async function* (_$, e) {
|
|
64
|
+
return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: { ...USAGE, model: 'claude-fable-5-1' } }
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const step = $.turn.step({ turnId: 't1', index: 0, model: 'claude-fable-5-1', messageCount: 1 })
|
|
68
|
+
for await (const _ of step);
|
|
69
|
+
await step.result
|
|
70
|
+
expect(writes.length).toBe(1)
|
|
71
|
+
const old = JSON.parse(writes.at(-1)?.text ?? '{}')
|
|
72
|
+
expect(old.cache?.last_request_at).toBeGreaterThan(0)
|
|
73
|
+
expect(old.context_window.current_usage).toEqual(USAGE)
|
|
74
|
+
expect(old.model.id).toBe('claude-fable-5-1')
|
|
75
|
+
|
|
76
|
+
await $.session.end({ reason: 'clear', sessionId: 'sid-old', resume: {} as never })
|
|
77
|
+
sid = 'sid-new'
|
|
78
|
+
await $.session.measure(MEASURE)
|
|
79
|
+
|
|
80
|
+
expect(writes.at(-1)?.path).toBe('C:/cfg/.cck/context-status/sid-new.json')
|
|
81
|
+
const fresh = JSON.parse(writes.at(-1)?.text ?? '{}')
|
|
82
|
+
expect(fresh.cache).toBeUndefined()
|
|
83
|
+
expect(fresh.context_window.current_usage).toBeNull()
|
|
84
|
+
expect(fresh.model.id).toBe('claude-opus-5-5')
|
|
85
|
+
})
|
|
86
|
+
|
|
56
87
|
test('toStatus adds the last request time', async () => {
|
|
57
88
|
const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
|
|
58
89
|
expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
|