claude-code-kanban 7.11.0 → 7.12.1
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/hub-app.json +1 -1
- package/lib/agent-log.js +17 -0
- package/lib/show.js +6 -4
- package/lib/worktrees.js +27 -9
- 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 +9 -6
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +8 -3
- package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +3 -1
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +16 -6
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +25 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +7 -0
- package/public/app.js +45 -35
- package/public/style.css +6 -2
- package/server.js +2 -7
package/hub-app.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
-
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+l", "ctrl+shift+m", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
9
|
"plugin": {
|
|
10
10
|
"marketplace": "plugin",
|
|
11
11
|
"name": "claude-code-kanban",
|
package/lib/agent-log.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A message to a stopped subagent spawns it again under the same id, and the plugin appends
|
|
4
|
+
// another start line. Each start line begins a new run, so nothing of the run before it shows.
|
|
5
|
+
function foldAgentLines(raw) {
|
|
6
|
+
let merged = {};
|
|
7
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
8
|
+
if (!line.trim()) continue;
|
|
9
|
+
let obj;
|
|
10
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
11
|
+
if (obj.event === 'start') merged = {};
|
|
12
|
+
Object.assign(merged, obj);
|
|
13
|
+
}
|
|
14
|
+
return merged;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { foldAgentLines };
|
package/lib/show.js
CHANGED
|
@@ -6,6 +6,7 @@ const crypto = require('node:crypto');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const express = require('express');
|
|
9
|
+
const { startFolder } = require('./folder-dialog');
|
|
9
10
|
const { httpError } = require('./http-error');
|
|
10
11
|
|
|
11
12
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -24,14 +25,15 @@ function isLabel(v) {
|
|
|
24
25
|
// A string is a reason the body is refused.
|
|
25
26
|
function parsePost(body) {
|
|
26
27
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
|
|
27
|
-
const { sessionId, title, key, kind = 'markdown', content, file } = body;
|
|
28
|
+
const { sessionId, title, key, kind = 'markdown', content, file, base } = body;
|
|
28
29
|
if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
|
|
29
30
|
if (!isLabel(title)) return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
30
31
|
if (key != null && !isLabel(key)) return `key must be a non-empty string of at most ${MAX_LABEL} characters`;
|
|
31
32
|
if (!Object.hasOwn(EXT, kind)) return 'kind must be markdown or html';
|
|
32
33
|
if (content != null) return 'content is not accepted: call show with only title and key to get a file to write the card into';
|
|
33
34
|
if (file != null && (typeof file !== 'string' || !path.isAbsolute(file))) return 'file must be an absolute path';
|
|
34
|
-
|
|
35
|
+
if (base != null && !startFolder(base)) return 'base must be an absolute path to an existing folder';
|
|
36
|
+
return { sessionId, title, key: key ?? null, kind, file: file ?? null, base: base ?? null };
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function writeAtomic(file, text) {
|
|
@@ -193,7 +195,7 @@ function createShowStore(o) {
|
|
|
193
195
|
// `kind` of a file post is the one readPreviewFile gave when it was posted. A post without a file
|
|
194
196
|
// is a claim: cck names the file and the session writes it. cck does not create it, because
|
|
195
197
|
// Claude Code's Write refuses to overwrite a file the session has not read.
|
|
196
|
-
function post(terminalId, { sessionId, title, key, kind, file }) {
|
|
198
|
+
function post(terminalId, { sessionId, title, key, kind, file, base }) {
|
|
197
199
|
const dir = folder(sessionId);
|
|
198
200
|
if (!dir) throw httpError(409, 'no transcript found for that session');
|
|
199
201
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -210,7 +212,7 @@ function createShowStore(o) {
|
|
|
210
212
|
seen.delete(id);
|
|
211
213
|
}
|
|
212
214
|
if (!old) at = posts.length;
|
|
213
|
-
const entry = { id, title, key, kind, file, updatedAt: new Date(now()).toISOString() };
|
|
215
|
+
const entry = { id, title, key, kind, file, base, updatedAt: new Date(now()).toISOString() };
|
|
214
216
|
posts[at] = entry;
|
|
215
217
|
writeIndex(dir, sessionId, posts);
|
|
216
218
|
bind(terminalId, sessionId);
|
package/lib/worktrees.js
CHANGED
|
@@ -34,6 +34,10 @@ function fromGitFile(dir, text) {
|
|
|
34
34
|
return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function pick(entry) {
|
|
38
|
+
return { repo: entry.repo, name: entry.name };
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
function fromPathShape(dir) {
|
|
38
42
|
const m = CLAUDE_WORKTREE_RE.exec(dir);
|
|
39
43
|
return m ? { repo: m[1], name: m[2] } : null;
|
|
@@ -67,20 +71,34 @@ function createWorktreeStore({ load, save, read = readGitFile, now = Date.now, d
|
|
|
67
71
|
});
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
// Walks up from `dir` the way git discovers its repo, so a session started in a subfolder of a
|
|
75
|
+
// worktree resolves too. A saved ancestor answers for a worktree whose checkout is gone; the
|
|
76
|
+
// claude path shape answers for one that was never seen.
|
|
77
|
+
function lookUp(dir) {
|
|
78
|
+
for (let d = dir, up; ; d = up) {
|
|
79
|
+
try {
|
|
80
|
+
return fromGitFile(d, read(d));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
|
|
83
|
+
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
84
|
+
if (e.code !== 'ENOENT') return null;
|
|
85
|
+
}
|
|
86
|
+
const shaped = fromPathShape(d);
|
|
87
|
+
if (shaped) return shaped;
|
|
88
|
+
up = path.dirname(d);
|
|
89
|
+
if (up === d) return null;
|
|
90
|
+
const hit = hits.get(up);
|
|
91
|
+
if (hit) return pick(hit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
function resolve(dir) {
|
|
71
96
|
if (!dir) return null;
|
|
72
97
|
const hit = hits.get(dir);
|
|
73
|
-
if (hit) return
|
|
98
|
+
if (hit) return pick(hit);
|
|
74
99
|
if (misses.has(dir)) return null;
|
|
75
100
|
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
worktree = fromGitFile(dir, read(dir));
|
|
79
|
-
} catch (e) {
|
|
80
|
-
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
|
|
81
|
-
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
82
|
-
if (e.code === 'ENOENT') worktree = fromPathShape(dir);
|
|
83
|
-
}
|
|
101
|
+
const worktree = lookUp(dir);
|
|
84
102
|
|
|
85
103
|
if (!worktree) {
|
|
86
104
|
misses.add(dir);
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.8",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
|
|
5
5
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineInterface, Register, ToolCallArgs } from 'claude-code'
|
|
1
|
+
import type { AgentSpawnInput, EngineInterface, Register, ToolCallArgs } from 'claude-code'
|
|
2
2
|
import { cckRoot } from './context'
|
|
3
3
|
|
|
4
4
|
// Mods cannot delete files. The server reads any status but "waiting" as no ask.
|
|
@@ -148,21 +148,24 @@ async function markStopped($: EngineInterface) {
|
|
|
148
148
|
await $.fs.write(`${await sessionDir($)}/_stop.json`, '')
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
// The name map lets TeammateIdle, which names the teammate, find its agent id.
|
|
152
|
-
|
|
151
|
+
// The name map lets TeammateIdle, which names the teammate, find its agent id. The prompt is on
|
|
152
|
+
// the start line because a resume runs a new prompt, and the server's own lookup finds the first.
|
|
153
|
+
async function agentStarted($: EngineInterface, e: AgentSpawnInput, agentId: string, model: string) {
|
|
153
154
|
const dir = await sessionDir($)
|
|
154
155
|
const ts = new Date().toISOString()
|
|
155
156
|
await Promise.all([
|
|
156
157
|
appendLine($, `${dir}/${agentId}.jsonl`, {
|
|
157
158
|
agentId,
|
|
158
|
-
type,
|
|
159
|
+
type: e.subagentType,
|
|
159
160
|
model,
|
|
160
161
|
event: 'start',
|
|
161
162
|
status: 'active',
|
|
163
|
+
...(e.prompt && { prompt: e.prompt }),
|
|
164
|
+
...(e.description && { description: e.description }),
|
|
162
165
|
startedAt: ts,
|
|
163
166
|
updatedAt: ts,
|
|
164
167
|
}),
|
|
165
|
-
$.fs.write(`${dir}/_name-${name ??
|
|
168
|
+
$.fs.write(`${dir}/_name-${e.name ?? e.subagentType}.id`, agentId),
|
|
166
169
|
])
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -249,7 +252,7 @@ export const register: Register = on => {
|
|
|
249
252
|
})
|
|
250
253
|
on('agent.spawn', async ($, e, next) => {
|
|
251
254
|
const result = await next(e)
|
|
252
|
-
if (result.agentId) await agentStarted($, result.agentId,
|
|
255
|
+
if (result.agentId) await agentStarted($, e, result.agentId, result.model)
|
|
253
256
|
return result
|
|
254
257
|
})
|
|
255
258
|
// An interrupted turn fires no Stop hook, so it does not mark the session unread either.
|
|
@@ -4,7 +4,7 @@ import { cckRoot } from './context'
|
|
|
4
4
|
export const TOOL = 'mcp__claude-code-kanban__show'
|
|
5
5
|
export const SKILL_LINE = /^\s*-\s*claude-code-kanban:show(?=:|\s*$)/
|
|
6
6
|
|
|
7
|
-
type ShowInput = { title: string; key?: string; kind?: 'markdown' | 'html'; file?: string }
|
|
7
|
+
type ShowInput = { title: string; key?: string; kind?: 'markdown' | 'html'; file?: string; base?: string }
|
|
8
8
|
type Shown = { title: string; key?: string | null; index: number; count: number; path?: string | null }
|
|
9
9
|
|
|
10
10
|
export function boardPort(cckUrl: string) {
|
|
@@ -67,8 +67,8 @@ async function post($: EngineInterface, input: ShowInput) {
|
|
|
67
67
|
if (!t) return { deny: 'The overlay needs a Claude Code session started in the cck terminal.' }
|
|
68
68
|
const token = await terminalToken($, t.url)
|
|
69
69
|
if (!token) return { deny: `No terminal token for the cck server at ${t.url}. It must run with the terminal enabled.` }
|
|
70
|
-
const { title, key, kind, file } = input
|
|
71
|
-
const body = JSON.stringify({ sessionId: await $.session.id(), title, key, kind, file })
|
|
70
|
+
const { title, key, kind, file, base } = input
|
|
71
|
+
const body = JSON.stringify({ sessionId: await $.session.id(), title, key, kind, file, base })
|
|
72
72
|
let res: Awaited<ReturnType<EngineInterface['http']['fetch']>>
|
|
73
73
|
try {
|
|
74
74
|
res = await $.http.fetch(`${t.url}/api/terminals/${encodeURIComponent(t.terminalId)}/show`, {
|
|
@@ -103,6 +103,11 @@ export const register: Register = on => {
|
|
|
103
103
|
},
|
|
104
104
|
file: { type: 'string', description: 'Absolute path of an existing markdown, HTML, image or text file' },
|
|
105
105
|
key: { type: 'string', description: 'Card name: the same key replaces the card in place and keeps its file' },
|
|
106
|
+
base: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description:
|
|
109
|
+
"Absolute path of the folder that the card's path:line links resolve against, usually the repo root. Always pass it with a call stack. Without it, links resolve against the session cwd, which is wrong for a worktree or another repo",
|
|
110
|
+
},
|
|
106
111
|
},
|
|
107
112
|
required: ['title'],
|
|
108
113
|
},
|
|
@@ -38,7 +38,9 @@ The board draws the tree lines, links, focus and folds from this notation:
|
|
|
38
38
|
- The fence tag is `callstack`.
|
|
39
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
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
|
|
41
|
+
- The note goes after 2 or more spaces: a `path:line` relative to the card's `base`, which the board links, or a few words. Line the notes up in one column.
|
|
42
|
+
- Post every card with a call stack with `base`: the absolute path of the repo root that holds the files. Without it, links resolve against the session cwd, which is a different checkout when you work in a worktree or another repo.
|
|
43
|
+
- Write the full relative path on each linked frame. A bare file name (`place.ts:40`) or a bare line (`:40`) never resolves.
|
|
42
44
|
- A `*` after the name, with one space, marks the frame to look at. One per stack.
|
|
43
45
|
- `+` 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
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: visualize
|
|
3
|
-
description: Present a topic as one
|
|
3
|
+
description: Present a topic as one self-contained HTML page — findings, options and a pick, a system overview. Use when the user asks to visualize something, or when an answer outgrows chat (options plus a pick, a diagram plus a table).
|
|
4
4
|
argument-hint: '[what to present]'
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ argument-hint: '[what to present]'
|
|
|
8
8
|
|
|
9
9
|
Present this: $ARGUMENTS. With no argument, present the subject of this conversation.
|
|
10
10
|
|
|
11
|
-
The page is a document to read:
|
|
11
|
+
The page is a document to read: its text and static exhibits are in the HTML, and it works offline. Two leading words run through it: the **pick** is the option the page recommends (on a page with no options, its main path), and an **exhibit** is the one table, figure or block that proves a section's claim.
|
|
12
12
|
|
|
13
13
|
## Steps
|
|
14
14
|
|
|
@@ -18,21 +18,31 @@ The page is a document to read: every mark is in the HTML, and it works offline
|
|
|
18
18
|
2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
|
|
19
19
|
3. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and at most 2 short paragraphs under it.
|
|
20
20
|
4. Close with what to do next and `.sources`.
|
|
21
|
-
3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for shapes, arrows and labels
|
|
22
|
-
4. **Check.** Render the page
|
|
21
|
+
3. **Invent each exhibit.** Ask what form makes this claim obvious at a glance, then build that, the template's blocks or something new: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map. The Blocks table is a palette of ready parts, not a menu to choose from. Before you draw an SVG, read the matching guide in the `show` skill — [chart](../show/references/chart.md), [diagram](../show/references/diagram.md) and its type file, [mockup](../show/references/mockup.md) — for patterns: shapes, arrows and labels. Their sizes and directions are for cards; on a page, the Principles below set them. Label masks fill with `--color-bg-subtle`, the figure's background. When the source or the request has a value the reader would want to change, or states one after another, the exhibit can be interactive: a slider or toggle that computes the figure again, a stepper through states, or a model that runs over time. Use the simplest one that proves the claim, and follow [Interactive exhibits](#interactive-exhibits). Never add a control on an input you made up: a claim that rests on fixed facts gets a static exhibit.
|
|
22
|
+
4. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when no text is clipped, no arrow crosses a label, `scrollWidth` equals `clientWidth`, and, when the topic comes from a source such as a file or a conversation, every section and number of it is named where it landed on the page or reported to the user as left out. For an interactive exhibit, also: no console errors, and the numbers the text states are the ones the exhibit shows at its default settings.
|
|
23
23
|
5. **Show.** When `$CCK_URL` is set, the session runs in the cck board: run `claude-code-kanban pane add <page> --title '<h1>'` and tell the user the page waits in that tab. Else give the user the page path.
|
|
24
24
|
|
|
25
25
|
## Principles
|
|
26
26
|
|
|
27
27
|
Every exhibit, ready or new, keeps these:
|
|
28
28
|
|
|
29
|
-
- **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. The rest stays muted. Status colors mean status.
|
|
30
|
-
- **Chart data takes series colors**: `--color-series-1` for the main series, `--color-text-muted` or `--color-series-3` for a second, up to 8 series; past that, fold the tail into "Other" or split into small multiples. The accent can sit close to `--color-danger`, so a whole data series in it reads as an alarm.
|
|
29
|
+
- **Accent marks the pick**, one or two marks per exhibit: its tile, card, row, node, or the one bar or point in a chart. In a chart that compares options, the pick's series keeps its series color and the accent goes on its label or end point. The rest stays muted. Status colors mean status.
|
|
30
|
+
- **Chart data takes series colors**: `--color-series-1` for the main series, `--color-text-muted` or `--color-series-3` for a second, up to 8 series; skip `--color-series-2`, which is the accent's hue; past that, fold the tail into "Other" or split into small multiples. The accent can sit close to `--color-danger`, so a whole data series in it reads as an alarm.
|
|
31
31
|
- **Tokens color everything**: `var(--color-*)`, tints by `color-mix(in srgb, var(--color-accent) 12%, var(--color-bg))`. Then the page follows light, dark and a host's theme with no script.
|
|
32
32
|
- **Fit**: an SVG has a `viewBox` up to 960 wide, no `width` attribute, text of 12 units or more. A flow runs left to right when it has 6 steps or fewer.
|
|
33
33
|
- **Remove test**: each node, arrow, label and decoration stays only if removing it loses meaning. A diagram past 9 nodes or 12 arrows splits into an overview and a detail. Each SVG has `role="img"` and `aria-labelledby` pointing at its `<title>`, with a per-figure id prefix.
|
|
34
34
|
- **Real**: real names, numbers and `file:line`, estimates and invented data marked, each claim cited. Repeated marks, such as small multiples, are generated while you write the page.
|
|
35
35
|
|
|
36
|
+
## Interactive exhibits
|
|
37
|
+
|
|
38
|
+
- An exhibit is the figure, its controls and the outputs they drive. The script draws the figure and writes only inside its exhibit; the text outside it is static HTML.
|
|
39
|
+
- Keep it small to edit: all inputs in one state object, one draw function that builds every figure from that state, and controls that only change state and call draw. Keep the computation apart from the drawing.
|
|
40
|
+
- Declare controls and presets in the markup, next to the text that names each scenario, and read them in one place. A new scenario is then one line of HTML.
|
|
41
|
+
- Colors come from the tokens on every draw, in canvas too, so a theme change needs no extra code.
|
|
42
|
+
- Nothing runs while the reader does nothing, except a running model that is on screen.
|
|
43
|
+
- Numbers from a model are marked as model numbers.
|
|
44
|
+
- The script goes at the end of `<body>`; its CSS goes in the second `<style>`.
|
|
45
|
+
|
|
36
46
|
## Blocks
|
|
37
47
|
|
|
38
48
|
Ready parts in the template's CSS; its body has one of each.
|
|
@@ -272,6 +272,31 @@ test('a subagent stop with an empty answer leaves lastMessage out', async ($, on
|
|
|
272
272
|
expect('lastMessage' in stop).toBe(false)
|
|
273
273
|
})
|
|
274
274
|
|
|
275
|
+
test('a resumed subagent appends a start line with the new task', async ($, on) => {
|
|
276
|
+
const files: Record<string, string> = {}
|
|
277
|
+
engine(on, files)
|
|
278
|
+
|
|
279
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
280
|
+
await $.turn.complete(turn({ agentId: 'a1', answer: 'first answer' }))
|
|
281
|
+
await $.agent.spawn({ ...spawn('general-purpose'), prompt: 'second task', description: '' })
|
|
282
|
+
|
|
283
|
+
const all = lines(files[`${SESSION}/a1.jsonl`])
|
|
284
|
+
expect(all.map((l) => l.event)).toEqual(['start', 'stop', 'start'])
|
|
285
|
+
expect(all[2]).toMatchObject({ status: 'active', prompt: 'second task' })
|
|
286
|
+
expect('description' in all[2]).toBe(false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a first spawn records its prompt and description', async ($, on) => {
|
|
290
|
+
const files: Record<string, string> = {}
|
|
291
|
+
engine(on, files)
|
|
292
|
+
|
|
293
|
+
await $.agent.spawn(spawn('general-purpose'))
|
|
294
|
+
|
|
295
|
+
const [start] = lines(files[`${SESSION}/a1.jsonl`])
|
|
296
|
+
expect(start).toMatchObject({ prompt: 'p', description: 'd' })
|
|
297
|
+
expect('lastMessage' in start).toBe(false)
|
|
298
|
+
})
|
|
299
|
+
|
|
275
300
|
test('a finished loop that was never spawned writes nothing', async ($, on) => {
|
|
276
301
|
const { writes } = engine(on, {})
|
|
277
302
|
|
|
@@ -102,6 +102,13 @@ test('posts the body with the terminal token and answers with the position', asy
|
|
|
102
102
|
])
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test('sends the base for the card links', async ($, on) => {
|
|
106
|
+
const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Stack', key: 'stack', index: 1, count: 1, path: 'C:/s/p1.md' } })
|
|
107
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
108
|
+
await $.tool.call(call({ title: 'Stack', key: 'stack', base: 'C:/proj-worktree' }))
|
|
109
|
+
expect(board.requests[0]?.body).toEqual({ sessionId: 'sid-1', title: 'Stack', key: 'stack', base: 'C:/proj-worktree' })
|
|
110
|
+
})
|
|
111
|
+
|
|
105
112
|
test('a claim sends no file and answers with the file to write', async ($, on) => {
|
|
106
113
|
const path = 'C:/s/.cck/show/p1.md'
|
|
107
114
|
const board = engine(on, GATE, { status: 200, body: { id: 'p1', title: 'Plan', key: 'plan', index: 1, count: 1, path } })
|
package/public/app.js
CHANGED
|
@@ -6433,8 +6433,8 @@ const SHORTCUT_TABS = [
|
|
|
6433
6433
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
6434
6434
|
{ keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
|
|
6435
6435
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
|
|
6436
|
-
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6437
|
-
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6436
|
+
{ keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
6437
|
+
{ keys: ['Ctrl', 'Shift', 'M'], combo: true, label: 'Open last message' },
|
|
6438
6438
|
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
6439
6439
|
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt; send review comments' },
|
|
6440
6440
|
],
|
|
@@ -7548,6 +7548,18 @@ function shiftOnly(e, code) {
|
|
|
7548
7548
|
return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
7549
7549
|
}
|
|
7550
7550
|
|
|
7551
|
+
function toggleLastMessage() {
|
|
7552
|
+
if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
7553
|
+
closeMsgDetailModal();
|
|
7554
|
+
} else if (isWaitingFresh()) {
|
|
7555
|
+
msgDetailFollowLatest = true;
|
|
7556
|
+
showWaitingDetail();
|
|
7557
|
+
} else if (currentMessages.length) {
|
|
7558
|
+
msgDetailFollowLatest = true;
|
|
7559
|
+
showMsgDetail(currentMessages.length - 1);
|
|
7560
|
+
}
|
|
7561
|
+
}
|
|
7562
|
+
|
|
7551
7563
|
function dismissSessionFromList(sid) {
|
|
7552
7564
|
const prevIdx = selectedSessionIdx;
|
|
7553
7565
|
if (!setSessionDismissed(sid, true)) return;
|
|
@@ -7843,16 +7855,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
7843
7855
|
}
|
|
7844
7856
|
if (shiftOnly(e, 'KeyM')) {
|
|
7845
7857
|
e.preventDefault();
|
|
7846
|
-
|
|
7847
|
-
if (msgDetailModal.classList.contains('visible')) {
|
|
7848
|
-
closeMsgDetailModal();
|
|
7849
|
-
} else if (isWaitingFresh()) {
|
|
7850
|
-
msgDetailFollowLatest = true;
|
|
7851
|
-
showWaitingDetail();
|
|
7852
|
-
} else if (currentMessages.length) {
|
|
7853
|
-
msgDetailFollowLatest = true;
|
|
7854
|
-
showMsgDetail(currentMessages.length - 1);
|
|
7855
|
-
}
|
|
7858
|
+
toggleLastMessage();
|
|
7856
7859
|
return;
|
|
7857
7860
|
}
|
|
7858
7861
|
if (shiftOnly(e, 'KeyS')) {
|
|
@@ -8619,20 +8622,21 @@ const ZOOM_CLAIMS = ['ctrlKey', 'metaKey'].flatMap((mod) =>
|
|
|
8619
8622
|
),
|
|
8620
8623
|
);
|
|
8621
8624
|
|
|
8622
|
-
// The
|
|
8623
|
-
//
|
|
8624
|
-
//
|
|
8625
|
+
// The board keys a pane gets before the board's inPane return, so they work while a previewed page
|
|
8626
|
+
// has focus; a new key there needs a row here. Matched by key where the board matches by key, so
|
|
8627
|
+
// they follow the keyboard layout, and by code where it matches by code. A page that handles one
|
|
8628
|
+
// first keeps it, and text fields keep every key.
|
|
8625
8629
|
const PANE_CLAIMS = [
|
|
8626
|
-
...['\\', '[', ']'].map((key) => ({ key, code: '' })),
|
|
8627
|
-
...['|', '{', '}'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8630
|
+
...['\\', '[', ']', 'ContextMenu', 'Escape'].map((key) => ({ key, code: '' })),
|
|
8631
|
+
...['|', '{', '}', 'F10'].map((key) => ({ shiftKey: true, key, code: '' })),
|
|
8628
8632
|
...['BracketLeft', 'BracketRight', 'KeyW'].map((code) => ({ altKey: true, key: 'Unidentified', code })),
|
|
8633
|
+
...['KeyL', 'KeyM', 'KeyP', 'KeyS', 'KeyZ'].map((code) => ({ shiftKey: true, key: 'Unidentified', code })),
|
|
8629
8634
|
];
|
|
8630
8635
|
|
|
8631
|
-
// Ctrl+D
|
|
8636
|
+
// Ctrl+D and Alt+Enter act on the show card (showCardKey); other pages keep them. Alt+W and Esc are in PANE_CLAIMS.
|
|
8632
8637
|
const SHOW_CLAIMS = [
|
|
8633
8638
|
...['ctrlKey', 'metaKey'].map((mod) => ({ [mod]: true, key: 'd', code: '' })),
|
|
8634
8639
|
{ altKey: true, key: 'Enter', code: '' },
|
|
8635
|
-
{ key: 'Escape', code: '' },
|
|
8636
8640
|
];
|
|
8637
8641
|
|
|
8638
8642
|
// Claimed only while comments wait, so a previewed page keeps Ctrl+Enter otherwise.
|
|
@@ -14780,6 +14784,8 @@ function terminalShortcut(e, probe = false) {
|
|
|
14780
14784
|
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
14781
14785
|
if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
|
|
14782
14786
|
if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
|
|
14787
|
+
if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
|
|
14788
|
+
if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
|
|
14783
14789
|
if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
|
|
14784
14790
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
14785
14791
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
@@ -16021,7 +16027,7 @@ const showState = {
|
|
|
16021
16027
|
path: null,
|
|
16022
16028
|
};
|
|
16023
16029
|
let showSize = store.readJson(SHOW_SIZE_KEY);
|
|
16024
|
-
// Terminal id → { closed?, collapsed?, expanded? };
|
|
16030
|
+
// Terminal id → { closed?, collapsed?, expanded? }; a terminal with no entry yet starts collapsed. Ids gone from the terminal list are pruned.
|
|
16025
16031
|
const showViews = store.readJson(SHOW_VIEW_KEY, {});
|
|
16026
16032
|
// Older boards stored the string 'collapsed' or 'expanded'.
|
|
16027
16033
|
for (const [tid, v] of Object.entries(showViews)) if (typeof v === 'string') showViews[tid] = { [v]: true };
|
|
@@ -16114,17 +16120,20 @@ function forgetEndedTerminals() {
|
|
|
16114
16120
|
if (!runningTerminals.has(showState.lookup)) showState.lookup = null;
|
|
16115
16121
|
}
|
|
16116
16122
|
|
|
16117
|
-
|
|
16123
|
+
// `post` is the id of the card on screen; a view without one keeps the saved id, so an empty list
|
|
16124
|
+
// while a terminal's posts load does not forget it.
|
|
16125
|
+
function setShowView(tid, view, post) {
|
|
16118
16126
|
const set = SHOW_VIEW_FLAGS.filter((f) => view[f]);
|
|
16119
|
-
const old = showViews[tid]
|
|
16120
|
-
|
|
16121
|
-
if (
|
|
16122
|
-
|
|
16127
|
+
const old = showViews[tid];
|
|
16128
|
+
const shown = post ?? old?.post;
|
|
16129
|
+
if (old && old.post === shown && SHOW_VIEW_FLAGS.every((f) => !!old[f] === set.includes(f))) return;
|
|
16130
|
+
showViews[tid] = Object.fromEntries(set.map((f) => [f, true]));
|
|
16131
|
+
if (shown) showViews[tid].post = shown;
|
|
16123
16132
|
store.writeJson(SHOW_VIEW_KEY, showViews);
|
|
16124
16133
|
}
|
|
16125
16134
|
|
|
16126
16135
|
function saveShowView() {
|
|
16127
|
-
if (showState.terminalId) setShowView(showState.terminalId, showState);
|
|
16136
|
+
if (showState.terminalId) setShowView(showState.terminalId, showState, showState.posts[showState.idx]?.id);
|
|
16128
16137
|
}
|
|
16129
16138
|
|
|
16130
16139
|
function resetShowPosts() {
|
|
@@ -16142,23 +16151,23 @@ function syncShowCard() {
|
|
|
16142
16151
|
}
|
|
16143
16152
|
const tid = pty ? (terminalIds.get(pty) ?? null) : null;
|
|
16144
16153
|
if (tid === showState.terminalId) return;
|
|
16145
|
-
const view = (tid && showViews[tid]) || {};
|
|
16154
|
+
const view = (tid && showViews[tid]) || { collapsed: true };
|
|
16146
16155
|
Object.assign(showState, { terminalId: tid, sessionId: null, idx: 0 });
|
|
16147
16156
|
for (const f of SHOW_VIEW_FLAGS) showState[f] = !!view[f];
|
|
16148
16157
|
resetShowPosts();
|
|
16149
16158
|
renderShow();
|
|
16150
|
-
if (tid) loadShowPosts(tid, {
|
|
16159
|
+
if (tid) loadShowPosts(tid, { shownId: view.post }).then((ok) => ok && renderShow());
|
|
16151
16160
|
}
|
|
16152
16161
|
|
|
16153
|
-
|
|
16162
|
+
// Opens `shownId` (by default the card on screen) when the list still holds it, else the newest.
|
|
16163
|
+
async function loadShowPosts(tid, { shownId = showState.posts[showState.idx]?.id } = {}) {
|
|
16154
16164
|
const seq = ++showState.listSeq;
|
|
16155
16165
|
const data = await getJson(apiPath`/api/terminals/${tid}/show`, null, { cache: 'no-store' });
|
|
16156
16166
|
if (seq !== showState.listSeq || tid !== showState.terminalId) return false;
|
|
16157
|
-
const shownId = showState.posts[showState.idx]?.id;
|
|
16158
16167
|
showState.sessionId = data?.sessionId ?? null;
|
|
16159
16168
|
showState.posts = Array.isArray(data?.posts) ? data.posts : [];
|
|
16160
16169
|
const at = showState.posts.findIndex((p) => p.id === shownId);
|
|
16161
|
-
showState.idx =
|
|
16170
|
+
showState.idx = at < 0 ? Math.max(0, showState.posts.length - 1) : at;
|
|
16162
16171
|
return true;
|
|
16163
16172
|
}
|
|
16164
16173
|
|
|
@@ -16231,10 +16240,11 @@ function showTicksHtml() {
|
|
|
16231
16240
|
}
|
|
16232
16241
|
|
|
16233
16242
|
function renderShow({ flash = false } = {}) {
|
|
16243
|
+
const n = showState.posts.length;
|
|
16244
|
+
if (n) showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
|
|
16234
16245
|
saveShowView();
|
|
16235
16246
|
const card = showCardEl();
|
|
16236
16247
|
const chip = document.getElementById('show-reopen');
|
|
16237
|
-
const n = showState.posts.length;
|
|
16238
16248
|
const open = n > 0 && !showState.closed;
|
|
16239
16249
|
chip.hidden = !n || open;
|
|
16240
16250
|
chip.querySelector('.show-reopen-n').textContent = `show · ${n}`;
|
|
@@ -16248,7 +16258,6 @@ function renderShow({ flash = false } = {}) {
|
|
|
16248
16258
|
card.replaceChildren();
|
|
16249
16259
|
return;
|
|
16250
16260
|
}
|
|
16251
|
-
showState.idx = Math.min(Math.max(0, showState.idx), n - 1);
|
|
16252
16261
|
const p = showState.posts[showState.idx];
|
|
16253
16262
|
showState.marked.delete(p.id);
|
|
16254
16263
|
card.classList.toggle('expanded', showState.expanded && !showState.collapsed);
|
|
@@ -17280,8 +17289,9 @@ function onCallstackLoc(e) {
|
|
|
17280
17289
|
e.preventDefault();
|
|
17281
17290
|
e.stopPropagation();
|
|
17282
17291
|
if (e.shiftKey) window.getSelection().collapseToEnd();
|
|
17283
|
-
const
|
|
17284
|
-
const
|
|
17292
|
+
const onCard = !!el.closest('#show-card');
|
|
17293
|
+
const sid = onCard ? showState.sessionId : currentSessionId;
|
|
17294
|
+
const base = (onCard && showState.posts[showState.idx]?.base) || (sid && getSessionBaseDir(sid)) || undefined;
|
|
17285
17295
|
if (e.shiftKey || !sid || el.closest('.modal-overlay')) openPreviewAtLine(loc, base);
|
|
17286
17296
|
else openPaneAtLine(sid, loc, base);
|
|
17287
17297
|
}
|
package/public/style.css
CHANGED
|
@@ -1058,10 +1058,14 @@ body.booting .boot-splash {
|
|
|
1058
1058
|
position: absolute;
|
|
1059
1059
|
inset: -1px;
|
|
1060
1060
|
border-left: 3px solid var(--accent);
|
|
1061
|
-
border-radius: inherit;
|
|
1062
1061
|
pointer-events: none;
|
|
1063
1062
|
}
|
|
1064
1063
|
|
|
1064
|
+
.session-item.unread::before,
|
|
1065
|
+
.session-item.permission-pending::before {
|
|
1066
|
+
border-radius: inherit;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1065
1069
|
.session-item.permission-pending::before,
|
|
1066
1070
|
.sp-row.permission-pending::before {
|
|
1067
1071
|
border-left-color: var(--warning);
|
|
@@ -2734,6 +2738,7 @@ code.ct .ct-loc {
|
|
|
2734
2738
|
color: var(--accent-text);
|
|
2735
2739
|
text-decoration: underline dotted;
|
|
2736
2740
|
text-underline-offset: 3px;
|
|
2741
|
+
cursor: pointer;
|
|
2737
2742
|
}
|
|
2738
2743
|
|
|
2739
2744
|
code.ct .ct-focus {
|
|
@@ -9013,7 +9018,6 @@ select.form-input option:checked {
|
|
|
9013
9018
|
align-items: baseline;
|
|
9014
9019
|
gap: 8px;
|
|
9015
9020
|
padding: 6px 8px;
|
|
9016
|
-
border-radius: 4px;
|
|
9017
9021
|
cursor: pointer;
|
|
9018
9022
|
font-size: 12px;
|
|
9019
9023
|
}
|
package/server.js
CHANGED
|
@@ -70,6 +70,7 @@ const { pickFolder } = require('./lib/folder-dialog');
|
|
|
70
70
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
71
71
|
const { countTaskDir } = require('./lib/task-counts');
|
|
72
72
|
const { readTaskDir } = require('./lib/task-dir');
|
|
73
|
+
const { foldAgentLines } = require('./lib/agent-log');
|
|
73
74
|
const { projectMatcher, normalizeProjectPath, sessionProjectKey } = require('./public/project-match');
|
|
74
75
|
const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
|
|
75
76
|
|
|
@@ -275,13 +276,7 @@ const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
|
|
|
275
276
|
// #region AGENT_ACTIVITY
|
|
276
277
|
|
|
277
278
|
function readAgentJsonl(filePath) {
|
|
278
|
-
|
|
279
|
-
const merged = {};
|
|
280
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
281
|
-
if (!line.trim()) continue;
|
|
282
|
-
try { Object.assign(merged, JSON.parse(line)); } catch (_) { /* skip malformed */ }
|
|
283
|
-
}
|
|
284
|
-
return merged;
|
|
279
|
+
return foldAgentLines(readFileSync(filePath, 'utf8'));
|
|
285
280
|
}
|
|
286
281
|
|
|
287
282
|
// Agent-activity record files in a session dir, excluding the `_`-prefixed sidecars
|