claude-code-kanban 7.13.0 → 7.14.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/cli.js CHANGED
@@ -141,6 +141,17 @@ const COMMANDS = {
141
141
  examples: ['claude-code-kanban session open $CLAUDE_SESSION_ID'],
142
142
  run: runSessionOpenCli,
143
143
  },
144
+ cost: {
145
+ summary: 'Show a session (or one subagent) in the Cost app',
146
+ usage: 'claude-code-kanban session cost <id> [--agent <agent id>]',
147
+ flags: {
148
+ '<id>': 'Full session id, or a unique prefix',
149
+ '--agent <agent id>': 'A subagent of the session (agent-…), as claude-code-cost session prints it',
150
+ },
151
+ notes: 'Under the hub the board switches to the Cost tab. The command also prints the cost link, for a board that runs alone.',
152
+ examples: ['claude-code-kanban session cost 3fa9c1 --agent agent-a10ff42'],
153
+ run: runSessionCostCli,
154
+ },
144
155
  view: {
145
156
  summary: 'Show full session stats (metadata + context window + cost) and the transcript path',
146
157
  usage: 'claude-code-kanban session view <id> [--json]',
@@ -943,6 +954,29 @@ async function runSessionOpenCli(args) {
943
954
  } catch (e) { reportCliError(e); return 1; }
944
955
  }
945
956
 
957
+ const AGENT_ID_RE = /^agent-[\w-]+$/;
958
+
959
+ async function runSessionCostCli(args) {
960
+ const entry = COMMANDS.session.verbs.cost;
961
+ const [idArg] = positionals(args, ['--agent']);
962
+ if (!idArg) {
963
+ printLeafHelp(entry);
964
+ return 1;
965
+ }
966
+ const agent = getArgValue(args, 'agent');
967
+ const agentGiven = args.some((a) => a === '--agent' || a.startsWith('--agent='));
968
+ if (agentGiven && !AGENT_ID_RE.test(agent || '')) return usageError(entry, '--agent takes an agent-… id.');
969
+ const resolved = await resolveSessionByIdOrPrefix(idArg);
970
+ if (!resolved) return 1;
971
+ try {
972
+ const out = await cliPostJson('/api/session/cost', { id: resolved.id, ...(agent && { agent }) }, 'Cost');
973
+ if (!out) return 1;
974
+ console.log(out.delivered ? 'Sent to the board: it opens the Cost tab.' : 'No hub board page is open to show it.');
975
+ if (out.url) console.log(`Cost view: ${out.url}`);
976
+ return out.delivered || out.url ? 0 : 1;
977
+ } catch (e) { reportCliError(e); return 1; }
978
+ }
979
+
946
980
  async function runSessionPinCli(args) {
947
981
  const idArg = args.find(a => !a.startsWith('--'));
948
982
  if (!idArg) {
@@ -1521,4 +1555,4 @@ async function runSkillsGetCli(args) {
1521
1555
  return 0;
1522
1556
  }
1523
1557
 
1524
- module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS };
1558
+ module.exports = { runCli, topHelp, COMMANDS, SERVER_FLAGS, AGENT_ID_RE };
package/lib/kanbot.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { encodeProjectDirName } = require('./claude-dir');
6
6
  const { MODELS } = require('./terminal');
7
+ const { isPidAlive } = require('./live-sessions');
7
8
  const { topHelp } = require('../cli');
8
9
 
9
10
  const PLUGIN_DIR = path.join(__dirname, '..', 'kanbot');
@@ -19,15 +20,56 @@ function kanbotModel(cfg) {
19
20
  return MODELS.has(model) ? model : null;
20
21
  }
21
22
 
22
- function writeHelpPrompt(cwd, boardUrl) {
23
+ function costSection(costCli) {
24
+ if (!costCli) return '';
25
+ return [
26
+ '\n# Costs\n',
27
+ `\`${costCli} help\` lists the commands for Claude Code costs: by day, model, project, session and subagent.`,
28
+ 'To show a session, or one of its subagents, to the user: `claude-code-kanban session cost <id> [--agent <agent id>]`.\n',
29
+ ].join('\n');
30
+ }
31
+
32
+ function writeHelpPrompt(cwd, boardUrl, costCli) {
23
33
  const file = path.join(cwd, 'cli-help.md');
24
34
  const board = boardUrl ? `This board runs at ${boardUrl}; \`$CCK_URL\` holds the same URL, so the CLI talks to it.\n\n` : '';
25
- fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n`);
35
+ fs.writeFileSync(file, `# claude-code-kanban CLI\n\n${board}\`claude-code-kanban help\` prints:\n\n\`\`\`\n${topHelp()}\n\`\`\`\n${costSection(costCli)}`);
26
36
  return file;
27
37
  }
28
38
 
29
- function kanbotArgs(cwd, claudeDir, boardUrl) {
30
- return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl), '--add-dir', claudeDir];
39
+ function kanbotArgs(cwd, claudeDir, boardUrl, costCli) {
40
+ return ['--plugin-dir', PLUGIN_DIR, '--agent', AGENT, '--append-system-prompt-file', writeHelpPrompt(cwd, boardUrl, costCli), '--add-dir', claudeDir];
41
+ }
42
+
43
+ // A claude-code-cost before its CLI starts a dashboard on any command, so a bin on PATH counts only
44
+ // when its package has lib/cli.js. npm puts the package next to a Windows shim, and a POSIX bin is
45
+ // a symlink to server.js.
46
+ function costPackageHasCli(bin) {
47
+ const dirs = [path.join(path.dirname(bin), 'node_modules', 'claude-code-cost')];
48
+ try {
49
+ dirs.push(path.dirname(fs.realpathSync(bin)));
50
+ } catch {}
51
+ return dirs.some((d) => fs.existsSync(path.join(d, 'lib', 'cli.js')));
52
+ }
53
+
54
+ // The hub gives cck no cost URL. The cost server of this config dir records itself in server.json
55
+ // ({pid, port, script}); a killed one leaves the file behind.
56
+ function readCostServer(claudeDir) {
57
+ let rec;
58
+ try {
59
+ rec = JSON.parse(fs.readFileSync(path.join(claudeDir, '.cost', 'server.json'), 'utf8'));
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!Number.isInteger(rec?.pid) || !Number.isInteger(rec?.port) || !isPidAlive(rec.pid)) return null;
64
+ return { url: `http://localhost:${rec.port}`, script: typeof rec.script === 'string' ? rec.script : null };
65
+ }
66
+
67
+ // Under the hub the CLI is not on PATH, so the running cost server names its own script.
68
+ function findCostCli({ claudeDir, which }) {
69
+ const script = readCostServer(claudeDir)?.script;
70
+ if (script) return `node "${script.replace(/\\/g, '/')}"`;
71
+ const bin = which('claude-code-cost');
72
+ return bin && costPackageHasCli(bin) ? 'claude-code-cost' : null;
31
73
  }
32
74
 
33
75
  function createKanbot({ cckDir, projectsDir }) {
@@ -70,10 +112,10 @@ function createKanbot({ cckDir, projectsDir }) {
70
112
  sessionId = id;
71
113
  ownIds.add(id);
72
114
  },
73
- startSpec({ model = null, boardUrl = null } = {}) {
115
+ startSpec({ model = null, boardUrl = null, costCli = null } = {}) {
74
116
  fs.mkdirSync(cwd, { recursive: true });
75
117
  sessionId = newestTranscript();
76
- const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl);
118
+ const extraArgs = kanbotArgs(cwd, path.dirname(cckDir), boardUrl, costCli);
77
119
  // Not restored on a cck restart: a restore would bring it back without the agent args.
78
120
  return { cwd, name: 'kanbot', model, extraArgs, restore: false, ...(sessionId && { resume: sessionId }) };
79
121
  },
@@ -83,4 +125,4 @@ function createKanbot({ cckDir, projectsDir }) {
83
125
  };
84
126
  }
85
127
 
86
- module.exports = { kanbotOn, kanbotModel, createKanbot, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
128
+ module.exports = { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer, KANBOT_AGENT: AGENT, KANBOT_PLUGIN_DIR: PLUGIN_DIR };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.13.0",
3
+ "version": "7.14.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.7.9",
3
+ "version": "3.7.11",
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
  }
@@ -16,7 +16,7 @@ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$
16
16
  |---|---|
17
17
  | `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
18
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>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
19
+ | `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `cost <id> [--agent <agent id>]` opens its costs · `pin <id>` · `group <id> <group>`, `ungroup <id>` in the sidebar |
20
20
  | `group` | `list` the sidebar groups, by name and id · `create <name>` · `rename <group> <name>` · `rm <group>` · `prune` the empty ones |
21
21
  | `task` | `list` for a session, a project or the whole board |
22
22
  | `project` | `list` |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: visualize
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).
3
+ description: Present a topic as one self-contained HTML page whose form fits the content — a diagram of a system, a chart of numbers over time, a model the reader can change, a comparison with a pick. Use when the user asks to visualize something, or when an answer outgrows chat.
4
4
  argument-hint: '[what to present]'
5
5
  ---
6
6
 
@@ -8,30 +8,35 @@ 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: 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.
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, and an **exhibit** is the one table, figure or block that proves a section's claim. A page with no options has no pick: never invent one to recommend.
12
+
13
+ The content sets the page's shape. The template gives the theme and a bare frame; [blocks.html](blocks.html) holds ready parts that share the theme, and a page takes only the parts its content has.
12
14
 
13
15
  ## Steps
14
16
 
15
- 1. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`; the `<style>` block stays byte for byte, and your own CSS goes in a second `<style>` after it.
16
- 2. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point:
17
- 1. `.kicker`, then `<title>` and `<h1>`: a name of 2 to 5 words. `.sub`: the question, in one line.
18
- 2. `.lead`: the answer in 1 or 2 sentences, the pick in `<b>`.
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
- 4. Close with what to do next and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
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
- 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.
17
+ 1. **Choose the form.** Before you open the template, name the content's shape (a system, a process, a timeline, a comparison, numbers that change, a set of findings) and the page's main exhibit: the one picture a reader remembers. State both in one line. The form makes the claim obvious at a glance. When the content has parts that connect, steps in order or states that change, the main exhibit is a diagram. Else a chart, an annotated timeline, small multiples, a working model or a ready part, whichever proves the claim.
18
+ 2. **Copy.** `cp` [template.html](template.html) from this skill's base directory to the output path. Edit the `<title>` and inside `<main>`. Leave the template's `<style>` block as it is, because it holds the theme tokens, and put your own CSS in a second `<style>` after it.
19
+ 3. **Write the story.** The `<h1>`, the lead and the `<h2>`s alone carry the whole point. A page has a title, a lead, sections and, when it cites, sources; every other part is there only when the content has that shape:
20
+ 1. `<title>` and `<h1>`: a name of 2 to 5 words. `.kicker` and `.sub` (the question, in one line) when they add something.
21
+ 2. `.lead`: the answer, short enough that a reader who stops here has it. When the page has a pick, name it in `<b>`; else `<b>` holds the main finding, or nothing.
22
+ 3. A lineup when the page compares options, stat tiles when it has a few headline numbers.
23
+ 4. Sections. Each `<h2>` is a claim that can be false ("A breaker alone trips again on release", not "Breaker"), with one exhibit and the short text that supports it.
24
+ 5. Close with what to do next, when there is a next step, and `.sources`, one `li` per source. Each `[n]` in the text is an `a.cite` to its row.
25
+ 4. **Build each exhibit.** Each section's claim gets the form that makes it obvious, as in step 1: a slope chart, an annotated timeline, small multiples, a cutaway, a ranked strip, a map, or a ready part. 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 is 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.
26
+ 5. **Check.** Render the page in light and in dark, and look at each render, not the source. Done when a reader who sees only a thumbnail can tell what the page is about, 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.
27
+ 6. **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
28
 
25
29
  ## Principles
26
30
 
27
31
  Every exhibit, ready or new, keeps these:
28
32
 
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.
33
+ - **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. With no pick, the accent marks the one thing the claim is about, or nothing. The rest stays muted. Status colors mean status.
30
34
  - **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
35
  - **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
36
  - **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
37
  - **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
38
  - **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.
39
+ - **Structure follows the content**: the sections, their order and the parts in them come from the data and the question, so two pages on different topics have different shapes. A part the content does not call for stays out: stat tiles with no headline numbers, next steps nobody asked for, a lineup with no options.
35
40
 
36
41
  ## Interactive exhibits
37
42
 
@@ -45,7 +50,7 @@ Every exhibit, ready or new, keeps these:
45
50
 
46
51
  ## Blocks
47
52
 
48
- Ready parts in the template's CSS; its body has one of each.
53
+ Ready parts in the template's CSS. [blocks.html](blocks.html) shows the markup of each; read the rows you use, in the order the story needs.
49
54
 
50
55
  | Block | Markup | Good for |
51
56
  |---|---|---|
@@ -0,0 +1,68 @@
1
+ <!-- Markup of each ready part; the CSS is in template.html. Copy a part only when the content has its shape. -->
2
+
3
+ <!-- Kicker and sub -->
4
+ <p class="kicker">Ticket or context · kind of page</p>
5
+ <p class="sub">The question this page answers.</p>
6
+
7
+ <!-- Lineup: a page that compares options; data-tone="accent" on the pick -->
8
+ <div class="lineup">
9
+ <div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
10
+ <div><b>2</b><span>Option B</span><small>too static</small></div>
11
+ </div>
12
+
13
+ <!-- Stat tiles: a few headline numbers -->
14
+ <div class="kpis">
15
+ <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes <a class="cite" href="#src-1">[1]</a></small></div>
16
+ <div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
17
+ </div>
18
+
19
+ <!-- Section with a label -->
20
+ <section>
21
+ <p class="eyebrow">Overview</p>
22
+ <h2>A heading is a claim that can be false</h2>
23
+ </section>
24
+
25
+ <!-- Cards: trade-offs of options -->
26
+ <div class="cards">
27
+ <div class="card" data-tone="accent">
28
+ <h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
29
+ <p class="verdict">The verdict, in one line.</p>
30
+ <p>What it is. <a class="cite" href="#src-2">[2]</a></p>
31
+ <pre>on 429: wait, then retry</pre>
32
+ <ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- Table and heatmap cells -->
37
+ <div class="table"><table>
38
+ <thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
39
+ <tbody>
40
+ <tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
41
+ <tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
42
+ <tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
43
+ </tbody>
44
+ </table></div>
45
+
46
+ <!-- Points: findings, each led by its claim -->
47
+ <ul class="points">
48
+ <li><b>A point leads with its claim.</b> The support.</li>
49
+ <li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
50
+ </ul>
51
+
52
+ <!-- Callout -->
53
+ <div class="callout" data-tone="warning"><p>A warning.</p></div>
54
+
55
+ <!-- Steps: what to do next -->
56
+ <ol class="steps"><li>First action.</li><li>Second action.</li></ol>
57
+
58
+ <!-- Legend -->
59
+ <div class="legend"><span><i data-tone="accent"></i>Pick</span><span><i></i>Other</span></div>
60
+
61
+ <!-- Sources with a closing line -->
62
+ <div class="sources">
63
+ <ol>
64
+ <li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
65
+ <li id="src-2"><a href="https://example.com">Title of the page</a><span>the retry on 429</span></li>
66
+ </ol>
67
+ <p>Estimates are marked as estimates.</p>
68
+ </div>
@@ -158,75 +158,21 @@ a.cite:hover { text-decoration: underline; }
158
158
  </head>
159
159
  <body>
160
160
  <main>
161
- <p class="kicker">Ticket or context · kind of page</p>
162
161
  <h1>Page Title</h1>
163
- <p class="sub">The question this page answers.</p>
164
- <p class="lead">The answer in one or two sentences, with <b>the pick</b> named, so a reader who stops here has it.</p>
165
-
166
- <div class="lineup">
167
- <div data-tone="accent"><b>1</b><span>Option A</span><small>the pick</small></div>
168
- <div><b>2</b><span>Option B</span><small>too static</small></div>
169
- <div><b>3</b><span>Option C</span><small>separate project</small></div>
170
- </div>
171
-
172
- <div class="kpis">
173
- <div class="kpi"><span>Errors in 7 days</span><b>133.5K</b><small>26% of minutes <a class="cite" href="#src-1">[1]</a></small></div>
174
- <div class="kpi" data-tone="danger"><span>Peak after release</span><b>30,000</b><small>RU per minute, estimate</small></div>
175
- </div>
162
+ <p class="lead">The answer, with <b>the main finding</b> named.</p>
176
163
 
177
164
  <section>
178
- <p class="eyebrow">Overview</p>
179
- <h2>Each heading is a claim that can be true or false</h2>
165
+ <h2>A heading is a claim that can be false</h2>
180
166
  <figure class="wide">
181
- <svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the diagram shows</title></svg>
182
- <figcaption>One sentence: what to notice.</figcaption>
167
+ <svg viewBox="0 0 900 200" role="img" aria-labelledby="f1-t"><title id="f1-t">What the figure shows</title></svg>
168
+ <figcaption>What to notice.</figcaption>
183
169
  </figure>
184
170
  </section>
185
171
 
186
- <section>
187
- <p class="eyebrow">Options</p>
188
- <h2>Cards lead with the verdict</h2>
189
- <div class="cards">
190
- <div class="card" data-tone="accent">
191
- <h3><span class="dot" data-tone="accent">1</span>Option A<span class="tag" data-tone="accent">pick</span></h3>
192
- <p class="verdict">The verdict, in one line.</p>
193
- <p>What it is, in one sentence. <a class="cite" href="#src-2">[2]</a></p>
194
- <pre>on 429: wait, then retry</pre>
195
- <ul><li class="pro">A strong point.</li><li class="con">A weak point.</li></ul>
196
- </div>
197
- </div>
198
- </section>
199
-
200
- <section>
201
- <p class="eyebrow">Compare</p>
202
- <h2>A table holds the matrix</h2>
203
- <div class="table"><table>
204
- <thead><tr><th>Option</th><th>Global</th><th>Lines</th></tr></thead>
205
- <tbody>
206
- <tr data-tone="accent"><td><span class="dot" data-tone="accent">1</span>Option A</td><td class="c yes">yes</td><td class="num">1,229</td></tr>
207
- <tr><td><span class="dot">2</span>Option B</td><td class="c part">partial</td><td class="num">640</td></tr>
208
- <tr><td><span class="dot">3</span>Option C</td><td class="c no">no</td><td class="num">300</td></tr>
209
- </tbody>
210
- </table></div>
211
- <ul class="points">
212
- <li><b>A point leads with its claim.</b> One or two sentences of support.</li>
213
- <li><b>Points sit side by side.</b> They wrap to one column on a phone.</li>
214
- </ul>
215
- </section>
216
-
217
- <section>
218
- <p class="eyebrow">Next</p>
219
- <h2>Steps say what to do next</h2>
220
- <ol class="steps"><li>First action.</li><li>Second action.</li></ol>
221
- </section>
222
-
223
172
  <div class="sources">
224
173
  <ol>
225
- <li id="src-1"><code>logs/errors-7d.csv</code><span>error count and share of minutes</span></li>
226
- <li id="src-2"><code>services/api/src/middleware/rate-limit/retry-after-handler.ts:42</code><span>the retry on 429</span></li>
227
- <li id="src-3"><a href="https://example.com">Title of the page</a><span>line counts in the table</span></li>
174
+ <li id="src-1"><code>path/to/file.ts:42</code><span>what it backs</span></li>
228
175
  </ol>
229
- <p>Estimates are marked as estimates.</p>
230
176
  </div>
231
177
  </main>
232
178
  </body>
package/public/app.js CHANGED
@@ -2604,9 +2604,18 @@ function replyLocateHint(timestamp, sid) {
2604
2604
  return `If that reply is not in your context, it is in ${transcript}, the assistant entry with timestamp ${timestamp}.`;
2605
2605
  }
2606
2606
 
2607
+ let msgDetailFromTerminal = false;
2608
+
2607
2609
  function closeMsgDetailModal() {
2608
2610
  unmountMsgReview();
2609
2611
  hideModalOverlay('msg-detail-modal');
2612
+ // A task later, so a modal opened right after this one (View Agent) keeps the focus.
2613
+ if (msgDetailFromTerminal) {
2614
+ setTimeout(() => {
2615
+ if (!isAnyModalOpen() && wantsTerminal()) focusTerminalPane();
2616
+ });
2617
+ }
2618
+ msgDetailFromTerminal = false;
2610
2619
  msgDetailFollowLatest = false;
2611
2620
  waitingDetailAutoOpened = false;
2612
2621
  // Drop the message highlight on close, mirroring task-card behavior.
@@ -7556,16 +7565,19 @@ function shiftOnly(e, code) {
7556
7565
  return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
7557
7566
  }
7558
7567
 
7568
+ // xterm runs in another process, so blur() in the board leaves it focused and the modal's arrow keys go
7569
+ // to it. Focusing a board element takes the focus.
7559
7570
  function toggleLastMessage() {
7560
7571
  if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
7561
7572
  closeMsgDetailModal();
7562
- } else if (isWaitingFresh()) {
7563
- msgDetailFollowLatest = true;
7564
- showWaitingDetail();
7565
- } else if (currentMessages.length) {
7566
- msgDetailFollowLatest = true;
7567
- showMsgDetail(currentMessages.length - 1);
7573
+ return;
7568
7574
  }
7575
+ if (!isWaitingFresh() && !currentMessages.length) return;
7576
+ msgDetailFromTerminal = terminalPaneFocused();
7577
+ msgDetailFollowLatest = true;
7578
+ if (isWaitingFresh()) showWaitingDetail();
7579
+ else showMsgDetail(currentMessages.length - 1);
7580
+ if (msgDetailFromTerminal) document.querySelector('#msg-detail-modal .msg-detail-dialog').focus();
7569
7581
  }
7570
7582
 
7571
7583
  function dismissSessionFromList(sid) {
@@ -10178,6 +10190,12 @@ function setupEventSource() {
10178
10190
  handleSessionOpenEvent(data);
10179
10191
  }
10180
10192
 
10193
+ // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10194
+ // CLI prints the link instead.
10195
+ if (data.type === 'session:cost' && hub.inHub) {
10196
+ openCost(data.id, data.agent);
10197
+ }
10198
+
10181
10199
  if (data.type === 'session:pin') {
10182
10200
  handleSessionPinEvent(data);
10183
10201
  }
@@ -12968,7 +12986,9 @@ function openFolderInEditor(folder, file) {
12968
12986
  postAndToast('/api/open-folder', body, 'folder');
12969
12987
  }
12970
12988
 
12971
- function openCost(sessionId) {
12989
+ function openCost(sessionId, agentId) {
12990
+ if (sessionId && agentId && hub.can('agent.cost'))
12991
+ return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
12972
12992
  hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
12973
12993
  }
12974
12994
 
package/public/index.html CHANGED
@@ -430,7 +430,7 @@
430
430
 
431
431
  <!-- Message Detail Modal -->
432
432
  <div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
433
- <div class="modal msg-detail-dialog review-host" onclick="event.stopPropagation()">
433
+ <div class="modal msg-detail-dialog review-host" tabindex="-1" onclick="event.stopPropagation()">
434
434
  <div class="modal-header">
435
435
  <h3 class="modal-title" id="msg-detail-title">Message</h3>
436
436
  <div style="display:flex;gap:4px;align-items:center;">
package/public/style.css CHANGED
@@ -7623,6 +7623,10 @@ body.light .msg-assistant .msg-text {
7623
7623
  overflow-y: auto;
7624
7624
  }
7625
7625
 
7626
+ .modal.msg-detail-dialog:focus {
7627
+ outline: none;
7628
+ }
7629
+
7626
7630
  /* Before .modal.fullscreen on purpose: at equal specificity the later
7627
7631
  fullscreen rule must win, same as for every other per-modal size. */
7628
7632
  .modal.msg-detail-dialog {
package/server.js CHANGED
@@ -48,7 +48,8 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
48
48
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
49
49
  const { readTerminalConfig } = require('./lib/terminal');
50
50
  const { createTerminalClient } = require('./lib/terminal-client');
51
- const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
51
+ const { kanbotOn, kanbotModel, createKanbot, findCostCli, readCostServer } = require('./lib/kanbot');
52
+ const { AGENT_ID_RE } = require('./cli');
52
53
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
53
54
  const { readLiveSessions, isPidAlive, noteReplacedSessions } = require('./lib/live-sessions');
54
55
  const { createProcStats } = require('./lib/proc-stats');
@@ -3526,6 +3527,7 @@ async function ensureKanbot() {
3526
3527
  const started = await terminal.startNew(kanbot.startSpec({
3527
3528
  model: kanbotModelSetting(),
3528
3529
  boardUrl: `http://127.0.0.1:${boardPort}`,
3530
+ costCli: findCostCli({ claudeDir: CLAUDE_DIR, which: whichSync }),
3529
3531
  }));
3530
3532
  if (started.error) return started;
3531
3533
  kanbot.ptyId = started.id;
@@ -3799,7 +3801,7 @@ async function confirmKind(absPath, kind) {
3799
3801
  }
3800
3802
 
3801
3803
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
3802
- const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
3804
+ const PREVIEW_MAX_BYTES = 16 * 1024 * 1024;
3803
3805
  // Source in a modal is for reading, not for scrolling a generated bundle.
3804
3806
  const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
3805
3807
 
@@ -3989,6 +3991,19 @@ app.post('/api/session/open', async (req, res) => {
3989
3991
  }
3990
3992
  });
3991
3993
 
3994
+ app.post('/api/session/cost', (req, res) => {
3995
+ const { id, agent } = req.body || {};
3996
+ if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
3997
+ if (agent !== undefined && (typeof agent !== 'string' || !AGENT_ID_RE.test(agent))) {
3998
+ return res.status(400).json({ error: 'agent must be an agent-… id' });
3999
+ }
4000
+ broadcast({ type: 'session:cost', id, ...(agent && { agent }) });
4001
+ const query = new URLSearchParams({ view: 'detail', session: id, ...(agent && { agent }) });
4002
+ const costUrl = COST_URL || readCostServer(CLAUDE_DIR)?.url;
4003
+ // A page opens the Cost tab only under the hub.
4004
+ res.json({ delivered: process.env.CLAUDE_HUB ? clients.size : 0, url: costUrl ? `${new URL(`?${query}`, costUrl)}` : null });
4005
+ });
4006
+
3992
4007
  // #region SESSION_PINS
3993
4008
  function broadcastPins(changed) {
3994
4009
  for (const { id, state } of changed) broadcast({ type: 'session:pin', id, state });