claude-code-kanban 5.3.0 → 6.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 +4 -4
- package/cli.js +30 -108
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +62 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +30 -5
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
- 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/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +257 -74
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +146 -147
- package/skill-guides/dispatch.md +16 -54
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
|
@@ -26,13 +26,9 @@ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
|
|
|
26
26
|
// because an enqueue wakes the poll immediately.
|
|
27
27
|
const WAIT_SEC = 120;
|
|
28
28
|
const RETRY_MS = 15000;
|
|
29
|
-
// Windows sometimes fails a loopback connect with ETIMEDOUT
|
|
30
|
-
//
|
|
29
|
+
// Windows sometimes fails a loopback connect with ETIMEDOUT, or resets it once open, while the
|
|
30
|
+
// board is up, so those errors get a few short waits before the normal one.
|
|
31
31
|
const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
|
|
32
|
-
// `--topic dispatch` is the dispatch inbox: reports from sessions this one started.
|
|
33
|
-
const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
|
|
34
|
-
// A dispatch report is a result, not an instruction, so a late attach still wants it.
|
|
35
|
-
const KEEP_BACKLOG = process.argv.includes('--keep-backlog');
|
|
36
32
|
|
|
37
33
|
if (!SESSION_ID) process.exit(0);
|
|
38
34
|
|
|
@@ -53,12 +49,11 @@ function serverUrl() {
|
|
|
53
49
|
// Once per process, not once per poll: the grant means "follow the board from here on", so
|
|
54
50
|
// the first attach throws away whatever queued up before it. A later reconnect must not
|
|
55
51
|
// discard again -- by then the queue holds events the user is owed.
|
|
56
|
-
let firstAttach =
|
|
52
|
+
let firstAttach = true;
|
|
57
53
|
|
|
58
54
|
async function poll(base) {
|
|
59
55
|
const first = firstAttach ? '&first=1' : '';
|
|
60
|
-
const
|
|
61
|
-
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}${topic}`;
|
|
56
|
+
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
|
|
62
57
|
const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
|
|
63
58
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
59
|
firstAttach = false;
|
|
@@ -75,8 +70,8 @@ async function poll(base) {
|
|
|
75
70
|
} catch (e) {
|
|
76
71
|
// No board yet, or it went away. It may come back later in the session, so keep
|
|
77
72
|
// waiting quietly -- a missing server is the normal case, not an error.
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
73
|
+
const blip = (e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect') || e.cause?.code === 'ECONNRESET';
|
|
74
|
+
if (blip && connectRetries < CONNECT_RETRY_MS.length) {
|
|
80
75
|
await sleep(CONNECT_RETRY_MS[connectRetries++]);
|
|
81
76
|
} else {
|
|
82
77
|
connectRetries = 0;
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dispatch
|
|
3
|
-
description: Dispatch
|
|
4
|
-
argument-hint: '<task> [--
|
|
3
|
+
description: Dispatch tasks to new Claude Code sessions in the kanban board's terminal. Use when the user asks to dispatch or delegate a task to another session.
|
|
4
|
+
argument-hint: '<task> [--handoff] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]] [-- <claude args>]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban dispatch
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Invoking this skill also arms this session's dispatch inbox: when a session you dispatched with `--report` reports or exits, a line arrives here:
|
|
12
|
-
|
|
13
|
-
```
|
|
14
|
-
[kanban board] Dispatch <dispatch-id> (session <uuid>) <reported success|reported failure|ended without a report>. Summary: <text>
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Load the guide before running any dispatch command:
|
|
9
|
+
The guide ships with the `claude-code-kanban` binary, so it always matches the commands that binary accepts. Load it before running any dispatch command:
|
|
18
10
|
|
|
19
11
|
```bash
|
|
20
12
|
claude-code-kanban skills get dispatch
|
|
21
13
|
```
|
|
22
14
|
|
|
23
15
|
Fall back to `npx claude-code-kanban` when the bare binary is not on PATH. If `skills get` is unknown, the installed cck is too old: tell the user to update it, and do not guess commands.
|
|
16
|
+
|
|
17
|
+
## Reply
|
|
18
|
+
|
|
19
|
+
End every spec with the reply line, because you usually need the result to continue:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
When you are done, or cannot finish, send the result to <your peer name> with the SendMessage tool, then stop.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`--handoff` is a skill argument that drops the reply line: the session owns the task, and the user follows it on the board. Keep it out of the `dispatch start` command.
|
|
26
|
+
|
|
27
|
+
## Orchestration patterns
|
|
28
|
+
|
|
29
|
+
Use request-reply, handoff or orchestrator-workers directly. When another shape fits better (a separate reviewer, steps that feed each other, a sub-orchestrator, a decision for the user), propose it in one line, the pattern and why, and dispatch only after the user agrees, unless the user named it. Details: [references/orchestration-patterns.md](references/orchestration-patterns.md).
|
package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Orchestration patterns
|
|
2
|
+
|
|
3
|
+
Each pattern is only `dispatch start` plus `SendMessage`; the specs decide who talks to whom. A **worker** is a session you dispatch.
|
|
4
|
+
|
|
5
|
+
## Select
|
|
6
|
+
|
|
7
|
+
Use one of these directly:
|
|
8
|
+
|
|
9
|
+
| Pattern | When |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Request-reply | The default |
|
|
12
|
+
| Handoff | The user passed `--handoff` or asked for no reply |
|
|
13
|
+
| Orchestrator-workers | The task splits into parts that do not depend on each other |
|
|
14
|
+
|
|
15
|
+
Propose these first, unless the user named one. Each adds steps, rounds or sessions the user did not ask for.
|
|
16
|
+
|
|
17
|
+
| Pattern | Fits when |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Prompt chaining | A step needs the previous step's output |
|
|
20
|
+
| Evaluator-optimizer | There is a clear acceptance bar and a second look improves the result |
|
|
21
|
+
| Hierarchical | A part is itself large enough to split |
|
|
22
|
+
| Human-in-the-loop | The spec leaves a choice the user should make |
|
|
23
|
+
|
|
24
|
+
## Request-reply
|
|
25
|
+
|
|
26
|
+
One worker, one reply. The reply can go to any peer. When another session owns the work and should act on the result, name it in the reply line, and ask for a one-line note to you as well, so you know the worker finished.
|
|
27
|
+
|
|
28
|
+
## Handoff
|
|
29
|
+
|
|
30
|
+
One worker, no reply line. The worker owns the task, and you save the turn a reply costs.
|
|
31
|
+
|
|
32
|
+
## Orchestrator-workers
|
|
33
|
+
|
|
34
|
+
One worker per independent part, all started before you wait on any. They run at the same time, and each holds only its own part in context. Give each a unique `--name` and one shared `--group`, then combine the replies.
|
|
35
|
+
|
|
36
|
+
## Prompt chaining
|
|
37
|
+
|
|
38
|
+
Workers in sequence; each spec carries the previous reply. You check each output before you start the next step, and stop the chain when a check fails.
|
|
39
|
+
|
|
40
|
+
## Evaluator-optimizer
|
|
41
|
+
|
|
42
|
+
A generator and an evaluator that message each other until the evaluator accepts. A separate evaluator judges the work with fresh eyes. Give each the other's name, and cap the rounds in both specs: `stop after 3 rounds and send what you have`.
|
|
43
|
+
|
|
44
|
+
## Hierarchical
|
|
45
|
+
|
|
46
|
+
A worker that dispatches its own workers and replies once they all have. Your context then holds one reply per part instead of one per leaf. Its spec says to wait for its workers and to give them its own peer name.
|
|
47
|
+
|
|
48
|
+
## Human-in-the-loop
|
|
49
|
+
|
|
50
|
+
Add to the worker's spec: `When you need a decision, ask <your peer name> with the SendMessage tool and keep working on what does not depend on the answer.` Ask the user when the decision is theirs, and reply with `SendMessage`. You can steer a running worker the same way at any time.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { On, ToolCallArgs, ToolCheckDecision } from 'claude-code'
|
|
2
|
+
import { type Engine, expect, type MockClock, mock, test } from 'claude-code/testing'
|
|
3
|
+
import { BOARD_ALLOWED, CLEARED } from '../hooks/activity'
|
|
4
|
+
|
|
5
|
+
const ROOT = 'C:/cfg/.cck/agent-activity'
|
|
6
|
+
const SESSION = `${ROOT}/sid-1`
|
|
7
|
+
const MARKER = `${SESSION}/_waiting.json`
|
|
8
|
+
const CONFIG = 'C:/cfg/.cck/config.json'
|
|
9
|
+
|
|
10
|
+
type EngineOptions = { decision?: ToolCheckDecision; rule?: string; env?: Record<string, string> }
|
|
11
|
+
|
|
12
|
+
// A test hook cannot raise tool.check, so the test raises it (askFor), and the engine's tool.call
|
|
13
|
+
// holds the first call until the test answers the terminal prompt.
|
|
14
|
+
function engine(on: On, files: Record<string, string>, { decision = 'allow', rule, env = {} }: EngineOptions = {}) {
|
|
15
|
+
const writes: { path: string; text: string }[] = []
|
|
16
|
+
const runs: unknown[] = []
|
|
17
|
+
const status: (string | undefined)[] = []
|
|
18
|
+
let answer = () => {}
|
|
19
|
+
const terminal = new Promise<void>(resolve => {
|
|
20
|
+
answer = resolve
|
|
21
|
+
})
|
|
22
|
+
const norm = (p: string) => p.replaceAll('\\', '/')
|
|
23
|
+
on('fs.read', (_$, e) => {
|
|
24
|
+
const text = files[norm(e.path)]
|
|
25
|
+
if (text === undefined) throw new Error(`ENOENT: ${e.path}`)
|
|
26
|
+
return { value: text }
|
|
27
|
+
})
|
|
28
|
+
on('fs.write', (_$, e) => {
|
|
29
|
+
writes.push({ path: norm(e.path), text: e.text })
|
|
30
|
+
files[norm(e.path)] = e.text
|
|
31
|
+
return { value: undefined }
|
|
32
|
+
})
|
|
33
|
+
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
|
|
34
|
+
on('session.id', () => ({ value: 'sid-1' }))
|
|
35
|
+
on('session.cwd', () => ({ value: 'C:/proj' }))
|
|
36
|
+
on('ui.status', (_$, e) => {
|
|
37
|
+
status.push(e.text)
|
|
38
|
+
return { value: undefined }
|
|
39
|
+
})
|
|
40
|
+
on('tool.check', () => ({ decision, rule }))
|
|
41
|
+
let held = decision === 'ask'
|
|
42
|
+
on('tool.call', async (_$, e) => {
|
|
43
|
+
const { tool, tool_use_id, agentId, ...input } = e
|
|
44
|
+
if (held) {
|
|
45
|
+
held = false
|
|
46
|
+
await terminal
|
|
47
|
+
}
|
|
48
|
+
runs.push(input)
|
|
49
|
+
return { result: { stdout: 'ok' } }
|
|
50
|
+
})
|
|
51
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
52
|
+
on('agent.spawn', () => ({ model: 'haiku', agentId: 'a1' }))
|
|
53
|
+
on('turn.complete', (_$, e) => ({ text: e.answer }))
|
|
54
|
+
on('classic.*', () => ({}))
|
|
55
|
+
return { writes, runs, status, answer }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function askFor($: Engine, clock: MockClock, args: { tool: string } & Record<string, unknown>) {
|
|
59
|
+
const { tool, ...input } = args
|
|
60
|
+
const call = $.tool.call(args as ToolCallArgs)
|
|
61
|
+
await clock.settle()
|
|
62
|
+
await $.tool.check({ tool, input })
|
|
63
|
+
await clock.settle()
|
|
64
|
+
return { call }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const marker = (files: Record<string, string>) => JSON.parse(files[MARKER]!)
|
|
68
|
+
const decide = (files: Record<string, string>, decision: object) => {
|
|
69
|
+
files[`${SESSION}/_decision-${marker(files).id}.json`] = JSON.stringify(decision)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lines = (text = '') => text.trim().split('\n').map(l => JSON.parse(l))
|
|
73
|
+
|
|
74
|
+
const turn = (fields: { agentId?: string; answer?: string; isAborted?: boolean }) => ({
|
|
75
|
+
answer: '',
|
|
76
|
+
durationMs: 1,
|
|
77
|
+
isAborted: false,
|
|
78
|
+
turnId: 't',
|
|
79
|
+
reason: 'answer' as const,
|
|
80
|
+
...fields,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const spawn = (subagentType: string, name?: string) => ({
|
|
84
|
+
tool_use_id: 'tu',
|
|
85
|
+
prompt: 'p',
|
|
86
|
+
description: 'd',
|
|
87
|
+
subagentType,
|
|
88
|
+
provider: { plugin: 'engine', tier: 'core' as const },
|
|
89
|
+
parentModel: 'opus',
|
|
90
|
+
background: false,
|
|
91
|
+
fork: false,
|
|
92
|
+
name,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('an allowed tool call writes nothing', async ($, on) => {
|
|
96
|
+
const { writes } = engine(on, {})
|
|
97
|
+
|
|
98
|
+
const result = await $.tool.call({ tool: 'Bash', command: 'ls' })
|
|
99
|
+
|
|
100
|
+
expect(result.result).toEqual({ stdout: 'ok' })
|
|
101
|
+
expect(writes).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('an ask writes the marker, and a terminal answer clears it', async ($, on) => {
|
|
105
|
+
const files: Record<string, string> = {}
|
|
106
|
+
const clock = mock.clock(on)
|
|
107
|
+
const { runs, answer } = engine(on, files, { decision: 'ask', rule: 'Bash(ls)' })
|
|
108
|
+
|
|
109
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
|
|
110
|
+
expect(marker(files)).toMatchObject({
|
|
111
|
+
status: 'waiting',
|
|
112
|
+
kind: 'permission',
|
|
113
|
+
toolName: 'Bash',
|
|
114
|
+
toolInput: '{"command":"ls"}',
|
|
115
|
+
cwd: 'C:/proj',
|
|
116
|
+
rule: 'Bash(ls)',
|
|
117
|
+
})
|
|
118
|
+
await clock.advance(1500)
|
|
119
|
+
answer()
|
|
120
|
+
|
|
121
|
+
expect((await call).result).toEqual({ stdout: 'ok' })
|
|
122
|
+
expect(files[MARKER]).toBe(CLEARED)
|
|
123
|
+
expect(runs).toEqual([{ command: 'ls' }])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a board allow runs the tool with the board input and shows why', async ($, on) => {
|
|
127
|
+
const files: Record<string, string> = {}
|
|
128
|
+
const clock = mock.clock(on)
|
|
129
|
+
const { runs, status } = engine(on, files, { decision: 'ask' })
|
|
130
|
+
|
|
131
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
|
|
132
|
+
decide(files, { behavior: 'allow', updatedInput: { command: 'ls -a' } })
|
|
133
|
+
await clock.advance(500)
|
|
134
|
+
|
|
135
|
+
expect((await call).result).toEqual({ stdout: 'ok' })
|
|
136
|
+
expect(runs).toEqual([{ command: 'ls -a' }])
|
|
137
|
+
expect(status).toEqual([BOARD_ALLOWED, undefined])
|
|
138
|
+
expect(files[MARKER]).toBe(CLEARED)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('a board deny refuses the call with its message', async ($, on) => {
|
|
142
|
+
const files: Record<string, string> = {}
|
|
143
|
+
const clock = mock.clock(on)
|
|
144
|
+
const { runs } = engine(on, files, { decision: 'ask' })
|
|
145
|
+
|
|
146
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'rm -rf x' })
|
|
147
|
+
decide(files, { behavior: 'deny', message: 'not that' })
|
|
148
|
+
await clock.advance(500)
|
|
149
|
+
|
|
150
|
+
expect((await call).deny).toBe('not that')
|
|
151
|
+
expect(runs).toEqual([])
|
|
152
|
+
expect(files[MARKER]).toBe(CLEARED)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('board answers to a question come back as the tool result', async ($, on) => {
|
|
156
|
+
const files: Record<string, string> = {}
|
|
157
|
+
const clock = mock.clock(on)
|
|
158
|
+
engine(on, files, { decision: 'ask' })
|
|
159
|
+
const questions = [{ question: 'Fruit?', header: 'Fruit', options: [], multiSelect: false }]
|
|
160
|
+
|
|
161
|
+
const { call } = await askFor($, clock, { tool: 'AskUserQuestion', questions })
|
|
162
|
+
expect(marker(files).kind).toBe('question')
|
|
163
|
+
decide(files, { answers: { 'Fruit?': 'Pear' } })
|
|
164
|
+
await clock.advance(500)
|
|
165
|
+
|
|
166
|
+
expect((await call).result).toEqual({ questions, answers: { 'Fruit?': 'Pear' } })
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('with approvals off the board only shows the ask', async ($, on) => {
|
|
170
|
+
const files: Record<string, string> = { [CONFIG]: '{"approvals":{"enabled":false}}' }
|
|
171
|
+
const clock = mock.clock(on)
|
|
172
|
+
const { runs, answer } = engine(on, files, { decision: 'ask' })
|
|
173
|
+
|
|
174
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
|
|
175
|
+
decide(files, { behavior: 'deny' })
|
|
176
|
+
await clock.advance(2000)
|
|
177
|
+
answer()
|
|
178
|
+
|
|
179
|
+
expect((await call).result).toEqual({ stdout: 'ok' })
|
|
180
|
+
expect(runs).toEqual([{ command: 'ls' }])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('the board stops waiting after waitSeconds', async ($, on) => {
|
|
184
|
+
const files: Record<string, string> = { [CONFIG]: '{"approvals":{"waitSeconds":1}}' }
|
|
185
|
+
const clock = mock.clock(on)
|
|
186
|
+
const { runs, answer } = engine(on, files, { decision: 'ask' })
|
|
187
|
+
|
|
188
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
|
|
189
|
+
await clock.advance(1500)
|
|
190
|
+
decide(files, { behavior: 'deny' })
|
|
191
|
+
await clock.advance(1000)
|
|
192
|
+
answer()
|
|
193
|
+
|
|
194
|
+
expect((await call).result).toEqual({ stdout: 'ok' })
|
|
195
|
+
expect(runs).toEqual([{ command: 'ls' }])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('a marker that a newer ask owns stays', async ($, on) => {
|
|
199
|
+
const files: Record<string, string> = {}
|
|
200
|
+
const clock = mock.clock(on)
|
|
201
|
+
const { answer } = engine(on, files, { decision: 'ask' })
|
|
202
|
+
|
|
203
|
+
const { call } = await askFor($, clock, { tool: 'Bash', command: 'ls' })
|
|
204
|
+
files[MARKER] = '{"status":"waiting","id":"newer"}'
|
|
205
|
+
answer()
|
|
206
|
+
await call
|
|
207
|
+
|
|
208
|
+
expect(files[MARKER]).toBe('{"status":"waiting","id":"newer"}')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('a finished main turn marks the session unread', async ($, on) => {
|
|
212
|
+
const files: Record<string, string> = {}
|
|
213
|
+
engine(on, files)
|
|
214
|
+
|
|
215
|
+
await $.turn.complete(turn({}))
|
|
216
|
+
|
|
217
|
+
expect(files[`${SESSION}/_stop.json`]).toBe('')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('an interrupted main turn does not mark the session unread', async ($, on) => {
|
|
221
|
+
const { writes } = engine(on, {})
|
|
222
|
+
|
|
223
|
+
await $.turn.complete(turn({ isAborted: true }))
|
|
224
|
+
|
|
225
|
+
expect(writes).toEqual([])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('session start maps the session to a custom task list', async ($, on) => {
|
|
229
|
+
const map = `${ROOT}/_task-maps/list-1.json`
|
|
230
|
+
const files: Record<string, string> = { [map]: '{"old":{"project":"C:/x"}}' }
|
|
231
|
+
engine(on, files, { env: { CLAUDE_CODE_TASK_LIST_ID: 'list-1' } })
|
|
232
|
+
|
|
233
|
+
await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
|
|
234
|
+
|
|
235
|
+
const maps = JSON.parse(files[map]!)
|
|
236
|
+
expect(maps.old).toEqual({ project: 'C:/x' })
|
|
237
|
+
expect(maps['sid-1'].project).toBe('C:/proj')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('session start writes nothing without a custom task list', async ($, on) => {
|
|
241
|
+
const { writes } = engine(on, {})
|
|
242
|
+
|
|
243
|
+
await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
|
|
244
|
+
|
|
245
|
+
expect(writes).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('a subagent spawn then finish records start and stop', async ($, on) => {
|
|
249
|
+
const files: Record<string, string> = {}
|
|
250
|
+
engine(on, files)
|
|
251
|
+
|
|
252
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
253
|
+
await $.turn.complete(turn({ agentId: 'a1', answer: 'Task done with "quotes"' }))
|
|
254
|
+
|
|
255
|
+
const [start, stop] = lines(files[`${SESSION}/a1.jsonl`])
|
|
256
|
+
expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', event: 'start', status: 'active' })
|
|
257
|
+
expect(stop).toMatchObject({ agentId: 'a1', event: 'stop', status: 'stopped', lastMessage: 'Task done with "quotes"' })
|
|
258
|
+
expect('type' in stop).toBe(false)
|
|
259
|
+
expect(files[`${SESSION}/_name-general-purpose.id`]).toBe('a1')
|
|
260
|
+
expect(files[`${SESSION}/_stop.json`]).toBeUndefined()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a subagent stop with an empty answer leaves lastMessage out', async ($, on) => {
|
|
264
|
+
const files: Record<string, string> = {}
|
|
265
|
+
engine(on, files)
|
|
266
|
+
|
|
267
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
268
|
+
await $.turn.complete(turn({ agentId: 'a1' }))
|
|
269
|
+
|
|
270
|
+
const [, stop] = lines(files[`${SESSION}/a1.jsonl`])
|
|
271
|
+
expect(stop).toMatchObject({ agentId: 'a1', event: 'stop' })
|
|
272
|
+
expect('lastMessage' in stop).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('a finished loop that was never spawned writes nothing', async ($, on) => {
|
|
276
|
+
const { writes } = engine(on, {})
|
|
277
|
+
|
|
278
|
+
await $.turn.complete(turn({ agentId: 'internal' }))
|
|
279
|
+
|
|
280
|
+
expect(writes).toEqual([])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('a named spawn maps the name, and TeammateIdle finds the agent by it', async ($, on) => {
|
|
284
|
+
const files: Record<string, string> = {}
|
|
285
|
+
engine(on, files)
|
|
286
|
+
|
|
287
|
+
await $.agent.spawn(spawn('general-purpose', 'researcher'))
|
|
288
|
+
await $.classic.TeammateIdle({ teammate_name: 'researcher', team_name: 'team' })
|
|
289
|
+
|
|
290
|
+
const idle = lines(files[`${SESSION}/a1.jsonl`])[1]
|
|
291
|
+
expect(idle).toMatchObject({ agentId: 'a1', type: 'researcher', event: 'idle', status: 'idle' })
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('TeammateIdle for an unknown teammate writes nothing', async ($, on) => {
|
|
295
|
+
const { writes } = engine(on, {})
|
|
296
|
+
|
|
297
|
+
await $.classic.TeammateIdle({ teammate_name: 'ghost', team_name: 'team' })
|
|
298
|
+
|
|
299
|
+
expect(writes).toEqual([])
|
|
300
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { On, SessionMeasureInput } from 'claude-code'
|
|
2
|
+
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
import { displayName } from '../hooks/context'
|
|
4
|
+
|
|
5
|
+
const MEASURE: SessionMeasureInput = {
|
|
6
|
+
context: { tokens: 129_174, window: 1_000_000, percent: 13 },
|
|
7
|
+
rateLimits: [
|
|
8
|
+
{ kind: 'five_hour', percentUsed: 1, resetsAt: '2026-10-02T13:10:00Z' },
|
|
9
|
+
{ kind: 'seven_day', percentUsed: 7.5, resetsAt: '2026-10-09T00:00:00Z' },
|
|
10
|
+
],
|
|
11
|
+
cost: { usd: 2.71 },
|
|
12
|
+
changed: ['context', 'rateLimits', 'cost'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function engine(on: On, env: Record<string, string>, sessionId: string) {
|
|
16
|
+
const writes: { path: string; text: string }[] = []
|
|
17
|
+
on('fs.write', (_$, e) => {
|
|
18
|
+
writes.push({ path: e.path.replaceAll('\\', '/'), text: e.text })
|
|
19
|
+
return { value: undefined }
|
|
20
|
+
})
|
|
21
|
+
on('env.get', (_$, e) => ({ value: env[e.name] }))
|
|
22
|
+
on('session.id', () => ({ value: sessionId }))
|
|
23
|
+
on('session.model', () => ({ value: 'claude-opus-5-5' }))
|
|
24
|
+
on('session.measure', (_$, e) => ({ changed: [...e.changed] }))
|
|
25
|
+
return writes
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('session.measure writes the statusLine shape the board reads', async ($, on) => {
|
|
29
|
+
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-1')
|
|
30
|
+
|
|
31
|
+
await $.session.measure(MEASURE)
|
|
32
|
+
|
|
33
|
+
expect(writes.length).toBe(1)
|
|
34
|
+
expect(writes[0]?.path).toBe('C:/cfg/.cck/context-status/sid-1.json')
|
|
35
|
+
const status = JSON.parse(writes[0]?.text ?? '{}')
|
|
36
|
+
expect(status.model).toEqual({ id: 'claude-opus-5-5', display_name: 'Opus 5.5' })
|
|
37
|
+
expect(status.cost.total_cost_usd).toBe(2.71)
|
|
38
|
+
expect(status.context_window).toEqual({
|
|
39
|
+
total_input_tokens: 129_174,
|
|
40
|
+
context_window_size: 1_000_000,
|
|
41
|
+
current_usage: null,
|
|
42
|
+
used_percentage: 13,
|
|
43
|
+
})
|
|
44
|
+
expect(status.rate_limits.five_hour).toEqual({ used_percentage: 1, resets_at: 1790946600 })
|
|
45
|
+
expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('skips a write when the status has not changed', async ($, on) => {
|
|
49
|
+
const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
|
|
50
|
+
|
|
51
|
+
await $.session.measure(MEASURE)
|
|
52
|
+
await $.session.measure(MEASURE)
|
|
53
|
+
await $.session.measure({ ...MEASURE, cost: { usd: 3 } })
|
|
54
|
+
|
|
55
|
+
expect(writes.length).toBe(2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('display name from model id', async () => {
|
|
59
|
+
expect(displayName('claude-opus-5-5')).toBe('Opus 5.5')
|
|
60
|
+
expect(displayName('claude-haiku-4-5-20251001')).toBe('Haiku 4.5')
|
|
61
|
+
expect(displayName('claude-fable-5-1[1m]')).toBe('Fable 5.1')
|
|
62
|
+
expect(displayName('claude-opus-4-20250514')).toBe('Opus 4')
|
|
63
|
+
expect(displayName('gpt-x')).toBe('gpt-x')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('falls back to ~/.claude when CLAUDE_CONFIG_DIR is unset', async ($, on) => {
|
|
67
|
+
const writes = engine(on, { USERPROFILE: 'C:/Users/me' }, 'sid-2')
|
|
68
|
+
|
|
69
|
+
await $.session.measure(MEASURE)
|
|
70
|
+
|
|
71
|
+
expect(writes.map(w => w.path)).toEqual(['C:/Users/me/.claude/.cck/context-status/sid-2.json'])
|
|
72
|
+
})
|