claude-code-kanban 7.4.0 → 7.6.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.
Files changed (48) hide show
  1. package/README.md +5 -3
  2. package/cli.js +95 -29
  3. package/hub-app.json +5 -0
  4. package/lib/panes.js +41 -6
  5. package/lib/parsers.js +42 -3
  6. package/lib/retention.js +54 -6
  7. package/lib/scratchpad-dir.js +19 -2
  8. package/lib/session-cache.js +1 -1
  9. package/lib/session-events.js +174 -47
  10. package/lib/show.js +385 -0
  11. package/lib/task-dir.js +26 -0
  12. package/lib/terminal-client.js +14 -1
  13. package/lib/terminal-host.js +2 -1
  14. package/lib/terminal.js +28 -8
  15. package/package.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
  17. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  20. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  21. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
  22. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  32. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  33. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  34. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  35. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  36. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  37. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
  38. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  39. package/public/app.js +2151 -1108
  40. package/public/index.html +19 -36
  41. package/public/style.css +1047 -125
  42. package/public/terminal-frame.js +0 -2
  43. package/public/tree-highlight.js +137 -15
  44. package/server.js +237 -178
  45. package/lib/dispatch.js +0 -32
  46. package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
  47. package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
  48. package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
@@ -0,0 +1,98 @@
1
+ # Explain card
2
+
3
+ A markdown card that shows how code works: what calls what, what a function decides, where things live, or what a change moves. Post `kind: "markdown"`; the board renders the fences below.
4
+
5
+ ## Make one point
6
+
7
+ Each card makes one point about the code under discussion. Write the point as a sentence or two, then the view that proves it. Pick the smallest view that makes the point clear; add a second view to the card only when it shows what the first cannot.
8
+
9
+ | Showing | View | Fence |
10
+ |---|---|---|
11
+ | Runtime flow: who calls whom | Call stack | `callstack` |
12
+ | What a function decides | Pseudocode | `text` |
13
+ | UI structure, state and module boundaries | Component tree | `tsx` |
14
+ | Which file owns what | File tree | `text` |
15
+ | Messages between parts over time | Sequence | `mermaid` |
16
+ | What a change adds, removes or moves | A diff of the view above | see [Diff](#diff) |
17
+
18
+ Keep only the calls, files, props and states the point needs, with real names from the code. Give the card a `key` that names the topic; post a follow-up view, such as the diff after the fix, with the same key so it replaces the card.
19
+
20
+ ## Call stack
21
+
22
+ The board draws the tree lines, links, focus and folds from this notation:
23
+
24
+ ```callstack
25
+ handleCheckout src/routes/checkout.ts:24
26
+ validateCart throws on an empty cart
27
+ placeOrder * src/orders/place.ts:40
28
+ chargeCard src/payments/charge.ts:31
29
+ ~ 2 SDK frames
30
+ PaymentsClient.charge node_modules/pay-sdk/client.js:112
31
+ HttpClient.request node_modules/pay-sdk/http.js:58
32
+ + sendReceipt src/email/receipt.ts:9
33
+ - logOrder src/orders/log.ts:5
34
+ onPaymentWebhook src/routes/webhooks.ts:58
35
+ markOrderPaid src/orders/status.ts:22
36
+ ```
37
+
38
+ - The fence tag is `callstack`.
39
+ - Caller on top. Indent each callee 2 spaces under its caller; the board turns the spaces into tree lines. Several roots are fine.
40
+ - One frame per line: the function or method name as one identifier, spelled as in the code.
41
+ - The note goes after 2 or more spaces: a `path:line` relative to the project root, which the board links, or a few words. Line the notes up in one column.
42
+ - A `*` after the name, with one space, marks the frame to look at. One per stack.
43
+ - `+` or `-` in the first column marks a frame the change adds or removes. In a stack with any `+` or `-`, every other line starts with one space.
44
+
45
+ ### Folds
46
+
47
+ A `~ N <what> frames` line stands for frames the reader can skip, such as framework, library or runtime code. It sits where those frames sit: under the frame that calls them, or at the top for framework callers above a handler.
48
+
49
+ - With frames indented under it, the line is a fold: the board shows it closed, and the reader clicks it to open.
50
+ - A fold that holds the `*` frame or a `+` or `-` frame starts open, so the point stays on screen.
51
+ - Folds nest: library frames that call runtime frames get a `~` each.
52
+ - With nothing under it, the line is a note that frames were left out. Write the frames out only when the reader may need them.
53
+
54
+ ## Pseudocode
55
+
56
+ ```text
57
+ placeOrder(cart)
58
+ if any item is out of stock
59
+ return "sold out"
60
+ charge the card
61
+ if the charge fails
62
+ release the stock
63
+ send the receipt
64
+ ```
65
+
66
+ ## Component tree
67
+
68
+ ```tsx
69
+ <CheckoutPage> (src/routes/checkout.tsx)
70
+ useCart()
71
+ <OrderSummary>
72
+ <PayButton> (packages/ui)
73
+ ```
74
+
75
+ ## File tree
76
+
77
+ The one view drawn with box characters:
78
+
79
+ ```text
80
+ src/
81
+ ├── routes/ # HTTP handlers
82
+ ├── orders/ # owns order state
83
+ └── payments/ # talks to the card provider
84
+ ```
85
+
86
+ ## Diff
87
+
88
+ Draw a change in the shape of the view it changes: a component tree diff for a component change, a file tree diff for a move. Mark each line with `+`, `-` or a space in the first column. A call stack takes the marks inside its `callstack` fence; every other view goes in a `diff` fence:
89
+
90
+ ```diff
91
+ <CheckoutPage>
92
+ useCart()
93
+ <OrderSummary>
94
+ + <PromoCodeInput />
95
+ <PayButton>
96
+ ```
97
+
98
+ Show the whole block without marks when most of it is new, or when a diff would hide the order or the owner of each part.
@@ -0,0 +1,30 @@
1
+ # Mockup card
2
+
3
+ A wireframe in an HTML card: a screen, a dialog, a form or a component, to agree on layout and wording before code.
4
+
5
+ ## Content
6
+
7
+ - Real labels and real copy from the task: "Delete 3 sessions?", not "Lorem ipsum" or "Button".
8
+ - Plain boxes in place of logos, brand names and product chrome. A gray block (`--color-bg-subtle`) with a mono caption stands in for an image, chart or map.
9
+ - One screen or one state per card. For a flow, post one card per step under one `key` prefix, or show 2 or 3 states side by side when they fit.
10
+ - Mark open questions with numbered callouts: a small circle in `--color-accent` with a number, and the notes as a list under the sketch.
11
+
12
+ ## Building blocks
13
+
14
+ The card already styles `button`, `button.primary`, `input`, `select`, `textarea`, `table` and headings from the tokens. Use them as they are.
15
+
16
+ ```html
17
+ <style>
18
+ .m { border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; }
19
+ .m-bar { display: flex; gap: 8px; align-items: center; padding: 8px 12px; background: var(--color-bg-subtle); border-bottom: 1px solid var(--color-border); }
20
+ .m-body { padding: 12px; display: grid; gap: 8px; }
21
+ .m-img { background: var(--color-bg-subtle); border: 1px dashed var(--color-border); border-radius: 6px; min-height: 64px; display: grid; place-items: center; font: 11px var(--font-mono); color: var(--color-text-muted); }
22
+ .m-note { display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; background: var(--color-accent); color: #fff; font: 600 11px var(--font-sans); }
23
+ </style>
24
+ ```
25
+
26
+ ## Fit the card
27
+
28
+ - A sketch narrower than the card sits centered (`margin-inline: auto`).
29
+ - A phone screen fits at real size, about 360 px. A desktop screen does not: sketch the part under discussion (one panel, the toolbar, the dialog), not the whole window.
30
+ - Scripts may toggle a state (open a menu, switch a tab) to show the interaction. Keep it to a few lines; the card has no storage and no network.
@@ -0,0 +1,133 @@
1
+ import type { On } from 'claude-code'
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+ import { boardUrl, parseReply, RETRY_MS } from '../hooks/doorbell'
4
+
5
+ test('the board URL comes from CCK_URL, else from server.json', async () => {
6
+ expect(boardUrl('http://127.0.0.1:4000/', '{"port":3541}')).toBe('http://127.0.0.1:4000')
7
+ expect(boardUrl(undefined, '{"port":3541}')).toBe('http://127.0.0.1:3541')
8
+ expect(boardUrl(undefined, '{"port":"3541"}')).toBe(undefined)
9
+ expect(boardUrl(undefined, 'not json')).toBe(undefined)
10
+ expect(boardUrl(undefined, undefined)).toBe(undefined)
11
+ })
12
+
13
+ type Reply = { status: number; events?: string[]; seqs?: number[]; board?: string }
14
+
15
+ function engine(on: On, replies: (() => Reply)[], submit?: () => Promise<unknown>) {
16
+ const urls: string[] = []
17
+ const prompts: string[] = []
18
+ let sessionId = 'sid-1'
19
+ on('fs.read', (_$, e) => {
20
+ if (e.path.replaceAll('\\', '/') === 'C:/cfg/.cck/server.json') return { value: '{"port":3541}' }
21
+ throw new Error(`ENOENT: ${e.path}`)
22
+ })
23
+ on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg' }[e.name] }))
24
+ on('session.id', () => ({ value: sessionId }))
25
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
26
+ on('http.fetch', async (_$, e) => {
27
+ urls.push(e.url)
28
+ const reply = replies.shift()
29
+ if (!reply) return new Promise(() => {})
30
+ const { status, events = [], seqs, board } = reply()
31
+ return { value: { status, ok: status < 300, headers: {}, text: JSON.stringify({ events, seqs, board }) } }
32
+ })
33
+ on('prompt.submit', async (_$, e) => {
34
+ prompts.push(e.text)
35
+ if (submit) await submit()
36
+ return { text: e.text }
37
+ })
38
+ return { urls, prompts, clear: () => (sessionId = 'sid-2') }
39
+ }
40
+
41
+ test('a -p run does not poll the board', async ($, on) => {
42
+ const clock = mock.clock(on)
43
+ const board = engine(on, [])
44
+ await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
45
+ await clock.settle()
46
+ expect(board.urls).toEqual([])
47
+ })
48
+
49
+ test('polls the board, submits a burst as one prompt, and drops the backlog for a new session id', async ($, on) => {
50
+ const clock = mock.clock(on)
51
+ const replies = [
52
+ () => ({ status: 200, events: ['line one', 'line two'] }),
53
+ () => ({ status: 503 }),
54
+ () => {
55
+ board.clear()
56
+ return { status: 200 }
57
+ },
58
+ () => ({ status: 200, events: ['after clear'] }),
59
+ ]
60
+ const board = engine(on, replies)
61
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
62
+ await clock.settle()
63
+
64
+ const base = 'http://127.0.0.1:3541/api/sessions'
65
+ expect(board.prompts).toEqual(['line one\nline two'])
66
+ expect(board.urls).toEqual([
67
+ `${base}/sid-1/events?wait=25&board=&got=0&ack=0&first=1`,
68
+ `${base}/sid-1/events?wait=25&board=&got=0&ack=0`,
69
+ ])
70
+
71
+ await clock.advance(RETRY_MS)
72
+ expect(board.urls.slice(2)).toEqual([
73
+ `${base}/sid-1/events?wait=25&board=&got=0&ack=0`,
74
+ `${base}/sid-2/events?wait=25&board=&got=0&ack=0&first=1&prev=sid-1`,
75
+ `${base}/sid-2/events?wait=25&board=&got=0&ack=0`,
76
+ ])
77
+ expect(board.prompts).toEqual(['line one\nline two', 'after clear'])
78
+ })
79
+
80
+ test('keeps polling while a submit waits, sends what came in as one prompt, and acks only after the submit', async ($, on) => {
81
+ const clock = mock.clock(on)
82
+ let release = () => {}
83
+ const replies = [
84
+ () => ({ status: 200, events: ['move'], seqs: [1], board: 'b1' }),
85
+ () => ({ status: 200, events: ['review one'], seqs: [2], board: 'b1' }),
86
+ () => ({ status: 200, events: ['review two', 'review one'], seqs: [3, 2], board: 'b1' }),
87
+ ]
88
+ let held = true
89
+ const board = engine(on, replies, () => (held ? new Promise<void>(r => (release = () => ((held = false), r()))) : Promise.resolve()))
90
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
91
+ await clock.settle()
92
+
93
+ const base = 'http://127.0.0.1:3541/api/sessions/sid-1/events?wait=25'
94
+ expect(board.prompts).toEqual(['move'])
95
+ expect(board.urls).toEqual([
96
+ `${base}&board=&got=0&ack=0&first=1`,
97
+ `${base}&board=b1&got=1&ack=0`,
98
+ `${base}&board=b1&got=2&ack=0`,
99
+ `${base}&board=b1&got=3&ack=0`,
100
+ ])
101
+
102
+ release()
103
+ await clock.settle()
104
+ expect(board.prompts).toEqual(['move', 'review one\nreview two'])
105
+ })
106
+
107
+ test('acks a submitted line on the next poll', async ($, on) => {
108
+ const clock = mock.clock(on)
109
+ const replies = [() => ({ status: 200, events: ['review'], seqs: [4], board: 'b1' }), () => ({ status: 200, board: 'b1' })]
110
+ const board = engine(on, replies)
111
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
112
+ await clock.settle()
113
+ expect(board.urls.at(-1)).toContain('board=b1&got=4&ack=4')
114
+ })
115
+
116
+ test('starts the count again when the board restarts', async ($, on) => {
117
+ const clock = mock.clock(on)
118
+ const replies = [
119
+ () => ({ status: 200, events: ['old'], seqs: [9], board: 'b1' }),
120
+ () => ({ status: 200, board: 'b1' }),
121
+ () => ({ status: 200, events: ['new'], seqs: [1], board: 'b2' }),
122
+ ]
123
+ const board = engine(on, replies)
124
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
125
+ await clock.settle()
126
+ expect(board.prompts).toEqual(['old', 'new'])
127
+ expect(board.urls.at(-1)).toContain('board=b2&got=1&ack=1')
128
+ })
129
+
130
+ test('reads a reply from a board without acks', () => {
131
+ expect(parseReply('{"events":["a",3,"b"]}')).toEqual({ lines: [{ text: 'a', seq: undefined }, { text: 'b', seq: undefined }], board: '' })
132
+ expect(parseReply('{"events":["a"],"seqs":[5],"board":"b1"}')).toEqual({ lines: [{ text: 'a', seq: 5 }], board: 'b1' })
133
+ })
@@ -0,0 +1,137 @@
1
+ import type { On, ToolCallArgs } from 'claude-code'
2
+ import { expect, test } from 'claude-code/testing'
3
+ import { boardPort, dropSkillLine, shownText, TOOL } from '../hooks/show'
4
+
5
+ const CCK_URL = 'http://127.0.0.1:4100'
6
+ const TERMINAL_ID = 'term-1'
7
+ const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
8
+ const LISTING = [
9
+ 'The following skills are available for use with the Skill tool:',
10
+ '',
11
+ '- claude-code-kanban:dispatch',
12
+ '- claude-code-kanban:show',
13
+ '- claude-code-kanban:kanban: Kanban board CLI',
14
+ '- meta:show-me: Help the user understand the current topic visually',
15
+ ].join('\n')
16
+
17
+ type Reply = { status: number; body: unknown } | Error
18
+ type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
19
+
20
+ function engine(on: On, env: Record<string, string>, reply?: Reply) {
21
+ const registered: string[] = []
22
+ const requests: Request[] = []
23
+ const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}' }
24
+ on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
25
+ on('fs.read', (_$, e) => {
26
+ const text = files[e.path.replaceAll('\\', '/')]
27
+ if (text === undefined) throw new Error(`ENOENT: ${e.path}`)
28
+ return { value: text }
29
+ })
30
+ on('session.id', () => ({ value: 'sid-1' }))
31
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
32
+ on('tool.register', (_$, e) => {
33
+ registered.push(e.name)
34
+ return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
35
+ })
36
+ on('prompt.attachment', (_$, e) => ({ text: e.text }))
37
+ on('http.fetch', (_$, e) => {
38
+ if (!e.url.includes('/show')) return new Promise(() => {})
39
+ const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
40
+ requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
41
+ if (!reply) throw new Error('no reply')
42
+ if (reply instanceof Error) return { deny: reply.message }
43
+ return { value: { status: reply.status, ok: reply.status < 300, headers: {}, text: JSON.stringify(reply.body) } }
44
+ })
45
+ return { registered, requests, files }
46
+ }
47
+
48
+ const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
49
+ const listing = { type: 'skill_listing', text: LISTING, origin: { kind: 'engine' } } as const
50
+ const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
51
+
52
+ test('helpers: the port of CCK_URL, the answer line, the listing filter', async () => {
53
+ expect(boardPort('http://127.0.0.1:4100')).toBe('4100')
54
+ expect(boardPort('http://localhost:3541/')).toBe('3541')
55
+ expect(boardPort('http://localhost')).toBe(undefined)
56
+ expect(shownText({ title: 'Plan', key: 'plan', index: 3, count: 7 })).toBe('Shown "Plan" (plan, 3/7)')
57
+ expect(shownText({ title: 'Map', key: null, index: 1, count: 1 })).toBe('Shown "Map" (1/1)')
58
+ expect(shownText({ title: 'Plan', key: 'plan', index: 1, count: 1, path: 'C:/s/p1.md' }, true)).toBe(
59
+ 'Card "Plan" (plan, 1/1) is ready. Write it to C:/s/p1.md; the card refreshes on each save.',
60
+ )
61
+ expect(dropSkillLine(LISTING)).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
62
+ expect(dropSkillLine('- claude-code-kanban:show: Show a diagram\n- x')).toBe('- x')
63
+ })
64
+
65
+ test('gate off: no tool, and the skill line is dropped', async ($, on) => {
66
+ const board = engine(on, { CCK_URL })
67
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
68
+ expect(board.registered).toEqual([])
69
+ const { text } = await $.prompt.attachment(listing)
70
+ expect(text).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
71
+ })
72
+
73
+ test('gate on: the tool is registered and the skill line stays', async ($, on) => {
74
+ const board = engine(on, GATE)
75
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
76
+ expect(board.registered).toEqual(['show'])
77
+ const { text } = await $.prompt.attachment(listing)
78
+ expect(text).toBe(LISTING)
79
+ })
80
+
81
+ test('a -p run gets no tool', async ($, on) => {
82
+ const board = engine(on, GATE)
83
+ await $.session.start({ cwd: 'C:/proj', surface: null, isInteractive: false })
84
+ expect(board.registered).toEqual([])
85
+ })
86
+
87
+ test('posts the body with the terminal token and answers with the position', async ($, on) => {
88
+ const board = engine(on, GATE, {
89
+ status: 200,
90
+ body: { id: 'p1', title: 'Plan', key: 'plan', index: 2, count: 3, replaced: false },
91
+ })
92
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
93
+ const res = await $.tool.call(call({ title: 'Plan', key: 'plan', kind: 'markdown', file: 'C:/proj/plan.md' }))
94
+ expect(res.result).toBe('Shown "Plan" (plan, 2/3)')
95
+ expect(board.requests).toEqual([
96
+ {
97
+ url: `${CCK_URL}/api/terminals/${TERMINAL_ID}/show`,
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json', 'x-terminal-token': 'tok-abc' },
100
+ body: { sessionId: 'sid-1', title: 'Plan', key: 'plan', kind: 'markdown', file: 'C:/proj/plan.md' },
101
+ },
102
+ ])
103
+ })
104
+
105
+ test('a claim sends no file and answers with the file to write', async ($, on) => {
106
+ const path = 'C:/s/.cck/show/p1.md'
107
+ const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Plan', key: 'plan', index: 1, count: 1, path } })
108
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
109
+ const res = await $.tool.call(call({ title: 'Plan', key: 'plan' }))
110
+ expect(res.result).toBe(`Card "Plan" (plan, 1/1) is ready. Write it to ${path}; the card refreshes on each save.`)
111
+ expect(board.requests[0]?.body).toEqual({ sessionId: 'sid-1', title: 'Plan', key: 'plan' })
112
+ })
113
+
114
+ test('a 400 answer reaches the model as an error with the server text', async ($, on) => {
115
+ const error = 'file not found: C:/proj/gone.md'
116
+ engine(on, GATE, { status: 400, body: { error } })
117
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
118
+ const res = await $.tool.call(call({ title: 'Gone', file: 'C:/proj/gone.md' }))
119
+ expect(res.deny).toBe(error)
120
+ })
121
+
122
+ test('a network error reaches the model as an error', async ($, on) => {
123
+ engine(on, GATE, new Error('connect ECONNREFUSED 127.0.0.1:4100'))
124
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
125
+ const res = await $.tool.call(call({ title: 'Plan' }))
126
+ expect(res.deny).toContain(`Cannot reach cck at ${CCK_URL}`)
127
+ expect(res.deny).toContain('ECONNREFUSED')
128
+ })
129
+
130
+ test('no token file: no request, an error that names the board', async ($, on) => {
131
+ const board = engine(on, GATE)
132
+ delete board.files[TOKEN_FILE]
133
+ await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
134
+ const res = await $.tool.call(call({ title: 'Plan' }))
135
+ expect(res.deny).toContain('No terminal token')
136
+ expect(board.requests).toEqual([])
137
+ })