claude-code-kanban 7.13.0 → 7.15.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/lib/parsers.js CHANGED
@@ -673,16 +673,41 @@ function countTranscriptsBornBefore(dir, birthtimeMs) {
673
673
  // Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
674
674
  // Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
675
675
  // to it (#50), so only a file that grew, shrank or was replaced counts as activity.
676
+ // When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
677
+ // mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
676
678
  const logActivityCache = new Map();
679
+ const BOOKKEEPING_APPEND_MAX = 64 * 1024;
680
+
681
+ function isBookkeepingAppend(jsonlPath, from, to) {
682
+ if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
683
+ // Reads from one byte early, so the append is known to start on a line boundary.
684
+ const buf = Buffer.allocUnsafe(to - from + 1);
685
+ try {
686
+ const fd = fs.openSync(jsonlPath, 'r');
687
+ try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
688
+ } catch { return false; }
689
+ const text = buf.toString('utf8');
690
+ if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
691
+ return text.slice(1, -1).split('\n').every((line) => {
692
+ if (!line) return true;
693
+ try {
694
+ const obj = JSON.parse(line);
695
+ return obj !== null && typeof obj === 'object';
696
+ } catch { return false; }
697
+ });
698
+ }
677
699
 
678
700
  function transcriptActivityMs(jsonlPath, stat) {
679
701
  const prev = logActivityCache.get(jsonlPath);
680
- if (prev && prev.ino === stat.ino && prev.size === stat.size) return prev.mtimeMs;
702
+ const sameFile = prev && prev.ino === stat.ino;
703
+ if (sameFile && prev.size === stat.size) return prev.mtimeMs;
704
+ const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
705
+ const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
681
706
  logActivityCache.delete(jsonlPath);
682
- logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs });
707
+ logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
683
708
  if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
684
709
  sessionCacheDirty = true;
685
- return stat.mtimeMs;
710
+ return mtimeMs;
686
711
  }
687
712
 
688
713
  const sessionCachesDirty = () => sessionCacheDirty;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.13.0",
3
+ "version": "7.15.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
@@ -369,7 +369,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
369
369
  schedulePublishSession();
370
370
  }
371
371
  // A list too short to scroll gives no scroll event, so fill it until it can scroll.
372
- if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
372
+ if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
373
373
 
374
374
  if (includeTasks && searchQuery) await loadSearchTasks();
375
375
  } catch (error) {
@@ -418,11 +418,11 @@ function resetSessionPage() {
418
418
  }
419
419
 
420
420
  const LOAD_MORE_MARGIN_PX = 240;
421
- function loadMoreIfNearEnd() {
422
- const { scrollTop, clientHeight, scrollHeight } = sessionsList;
423
- if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) loadMoreSessions();
421
+ function loadMoreIfNearEnd(el = sessionsList, load = loadMoreSessions) {
422
+ const { scrollTop, clientHeight, scrollHeight } = el;
423
+ if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) load();
424
424
  }
425
- sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
425
+ sessionsList.addEventListener('scroll', () => loadMoreIfNearEnd(), { passive: true });
426
426
 
427
427
  // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
428
428
  async function fetchSessionsByIds(ids) {
@@ -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.
@@ -6437,6 +6446,7 @@ const SHORTCUT_TABS = [
6437
6446
  { keys: ['I'], label: 'Session info' },
6438
6447
  { keys: ['.'], label: 'Pin / unpin' },
6439
6448
  { keys: ['>'], label: 'Toggle sticky' },
6449
+ { keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
6440
6450
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6441
6451
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
6442
6452
  { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
@@ -7556,16 +7566,19 @@ function shiftOnly(e, code) {
7556
7566
  return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
7557
7567
  }
7558
7568
 
7569
+ // xterm runs in another process, so blur() in the board leaves it focused and the modal's arrow keys go
7570
+ // to it. Focusing a board element takes the focus.
7559
7571
  function toggleLastMessage() {
7560
7572
  if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
7561
7573
  closeMsgDetailModal();
7562
- } else if (isWaitingFresh()) {
7563
- msgDetailFollowLatest = true;
7564
- showWaitingDetail();
7565
- } else if (currentMessages.length) {
7566
- msgDetailFollowLatest = true;
7567
- showMsgDetail(currentMessages.length - 1);
7574
+ return;
7568
7575
  }
7576
+ if (!isWaitingFresh() && !currentMessages.length) return;
7577
+ msgDetailFromTerminal = terminalPaneFocused();
7578
+ msgDetailFollowLatest = true;
7579
+ if (isWaitingFresh()) showWaitingDetail();
7580
+ else showMsgDetail(currentMessages.length - 1);
7581
+ if (msgDetailFromTerminal) document.querySelector('#msg-detail-modal .msg-detail-dialog').focus();
7569
7582
  }
7570
7583
 
7571
7584
  function dismissSessionFromList(sid) {
@@ -10178,6 +10191,12 @@ function setupEventSource() {
10178
10191
  handleSessionOpenEvent(data);
10179
10192
  }
10180
10193
 
10194
+ // Alone, the page would open a tab without a user gesture, which the browser blocks; the
10195
+ // CLI prints the link instead.
10196
+ if (data.type === 'session:cost' && hub.inHub) {
10197
+ openCost(data.id, data.agent);
10198
+ }
10199
+
10181
10200
  if (data.type === 'session:pin') {
10182
10201
  handleSessionPinEvent(data);
10183
10202
  }
@@ -12255,6 +12274,52 @@ let spFromTerminal = false;
12255
12274
  let spRenderedQuery = null;
12256
12275
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
12257
12276
  const spPeekCache = new Map();
12277
+ // 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
12278
+ // every session, newest first, paged like the sidebar's All filter. Each open starts on 'board'.
12279
+ let spScope = 'board';
12280
+ let spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12281
+ let spRowEls = [];
12282
+ const SP_RECENT_PAGE = 50;
12283
+ const SP_RECENT_TAIL = 5;
12284
+
12285
+ // The first load asks for the board's count plus the tail, so the tail is there even when
12286
+ // the newest sessions are all on the board.
12287
+ async function spLoadRecent() {
12288
+ const state = spRecent;
12289
+ if (state.loading || !state.hasMore) return;
12290
+ state.loading = true;
12291
+ state.limit = state.limit ? state.limit + SP_RECENT_PAGE : spSource.length + SP_RECENT_TAIL;
12292
+ try {
12293
+ const res = await api('/api/sessions', { query: { limit: state.limit, pins: 'off' } });
12294
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
12295
+ const rows = await res.json();
12296
+ if (state !== spRecent) return;
12297
+ state.rows = rows;
12298
+ state.hasMore = res.headers.get('X-Has-More') === 'true';
12299
+ } catch (error) {
12300
+ console.error('Failed to load recent sessions:', error);
12301
+ state.hasMore = false;
12302
+ } finally {
12303
+ state.loading = false;
12304
+ }
12305
+ if (!isSessionPickerOpen()) return;
12306
+ renderSessionPicker();
12307
+ spLoadMoreNearEnd();
12308
+ }
12309
+
12310
+ function spSetScope(scope) {
12311
+ spScope = scope;
12312
+ for (const el of document.querySelectorAll('#session-picker-scope [data-scope]')) {
12313
+ el.setAttribute('aria-checked', String(el.dataset.scope === scope));
12314
+ }
12315
+ renderSessionPicker();
12316
+ spLoadMoreNearEnd();
12317
+ }
12318
+
12319
+ // Also run after each load: a page too short to scroll gives no scroll event.
12320
+ function spLoadMoreNearEnd() {
12321
+ if (spScope === 'all') loadMoreIfNearEnd(document.getElementById('session-picker-list'), spLoadRecent);
12322
+ }
12258
12323
 
12259
12324
  const spSearch = sessionSearch(() => {
12260
12325
  if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
@@ -12267,9 +12332,11 @@ function openSessionPicker() {
12267
12332
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
12268
12333
  spPeekCache.clear();
12269
12334
  spRenderedQuery = null;
12335
+ spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12270
12336
  document.getElementById('session-picker-modal').classList.add('visible');
12271
- renderSessionPicker();
12337
+ spSetScope('board');
12272
12338
  input.focus();
12339
+ spLoadRecent();
12273
12340
  }
12274
12341
 
12275
12342
  // refocus: false when a session is being opened, which moves the terminal focus itself.
@@ -12385,29 +12452,39 @@ function spPinHtml(session) {
12385
12452
  function renderSessionPicker() {
12386
12453
  const list = document.getElementById('session-picker-list');
12387
12454
  const query = document.getElementById('session-picker-input').value.trim();
12388
- // Search rows that arrive for the same query append below, so the cursor stays on its row.
12389
- const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
12390
- spRenderedQuery = query;
12391
- const local = spSource.filter((s) => spMatches(s, query));
12455
+ // Rows that arrive for the same query and scope append below, so the cursor stays on its row.
12456
+ const renderKey = `${spScope}\n${query}`;
12457
+ const selectedId = renderKey === spRenderedQuery ? spRows[spIdx]?.id : null;
12458
+ spRenderedQuery = renderKey;
12459
+ const all = spScope === 'all';
12460
+ const board = new Set(spSource.map((s) => s.id));
12461
+ const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
12392
12462
  const found = spSearch.rows(query, local);
12393
- spRows = [...local, ...found.rows];
12463
+ const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
12464
+ const extra = [...found.rows, ...tail];
12465
+ spRows = [...local, ...extra];
12394
12466
  if (!spRows.length) {
12395
12467
  spIdx = -1;
12396
- const empty = found.pending
12397
- ? 'Searching all sessions…'
12398
- : spSource.length || query.length >= SESSION_SEARCH_MIN
12399
- ? 'No session matches'
12400
- : 'No sessions in the current sidebar filter';
12468
+ spRowEls = [];
12469
+ const empty =
12470
+ found.pending || (all && spRecent.loading)
12471
+ ? 'Searching all sessions…'
12472
+ : all || spSource.length || query.length >= SESSION_SEARCH_MIN
12473
+ ? 'No session matches'
12474
+ : 'No sessions in the current sidebar filter';
12401
12475
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
12402
12476
  return;
12403
12477
  }
12404
12478
 
12405
- // Rows past the sidebar list come from the server search and show their full project path.
12479
+ // Rows not on the board show their full project path; in the board scope they also sit below
12480
+ // the board's rows, under a label.
12481
+ const section = !all && extra.length ? '<div class="sp-section">Not on board</div>' : '';
12406
12482
  list.innerHTML = spRows
12407
12483
  .map((s, i) => {
12408
- const outside = i >= local.length;
12484
+ const outside = !board.has(s.id);
12485
+ const below = !all && i >= local.length;
12409
12486
  const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
12410
- return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12487
+ return `${below && i === local.length ? section : ''}<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${below ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12411
12488
  ${spDotHtml(s)}
12412
12489
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
12413
12490
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -12418,18 +12495,21 @@ function renderSessionPicker() {
12418
12495
  </button>`;
12419
12496
  })
12420
12497
  .join('');
12498
+ // The section label sits between rows, so row index and child index differ.
12499
+ spRowEls = list.querySelectorAll('.sp-row');
12500
+ spIdx = -1;
12421
12501
  const keep = spRows.findIndex((s) => s.id === selectedId);
12422
12502
  spSelect(Math.max(0, keep));
12423
12503
  }
12424
12504
 
12425
12505
  function spSelect(idx) {
12426
- const list = document.getElementById('session-picker-list');
12427
- list.children[spIdx]?.classList.remove('selected');
12506
+ spRowEls[spIdx]?.classList.remove('selected');
12428
12507
  spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
12429
- const row = list.children[spIdx];
12508
+ const row = spRowEls[spIdx];
12430
12509
  row?.classList.add('selected');
12431
12510
  row?.scrollIntoView({ block: 'nearest' });
12432
12511
  spSchedulePeek();
12512
+ if (spScope === 'all' && spIdx >= spRows.length - 5) spLoadRecent();
12433
12513
  }
12434
12514
 
12435
12515
  async function spOpen(idx) {
@@ -12450,14 +12530,22 @@ function initSessionPicker() {
12450
12530
  else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
12451
12531
  else if (e.key === 'Enter') spOpen(spIdx);
12452
12532
  else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
12533
+ else if (e.key === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) spSetScope(spScope === 'all' ? 'board' : 'all');
12453
12534
  else return;
12454
12535
  e.preventDefault();
12455
12536
  e.stopPropagation();
12456
12537
  });
12457
- document.getElementById('session-picker-list').addEventListener('click', (e) => {
12538
+ const list = document.getElementById('session-picker-list');
12539
+ list.addEventListener('click', (e) => {
12458
12540
  const row = e.target.closest('.sp-row');
12459
12541
  if (row) spOpen(Number(row.dataset.idx));
12460
12542
  });
12543
+ list.addEventListener('scroll', spLoadMoreNearEnd, { passive: true });
12544
+ document.getElementById('session-picker-scope').addEventListener('click', (e) => {
12545
+ const scope = e.target.closest('[data-scope]')?.dataset.scope;
12546
+ if (scope) spSetScope(scope);
12547
+ input.focus();
12548
+ });
12461
12549
  // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
12462
12550
  // A blur that no click in the picker caused, while the window keeps focus, is that Escape.
12463
12551
  const modal = document.getElementById('session-picker-modal');
@@ -12968,7 +13056,9 @@ function openFolderInEditor(folder, file) {
12968
13056
  postAndToast('/api/open-folder', body, 'folder');
12969
13057
  }
12970
13058
 
12971
- function openCost(sessionId) {
13059
+ function openCost(sessionId, agentId) {
13060
+ if (sessionId && agentId && hub.can('agent.cost'))
13061
+ return hub.invoke('agent.cost', { session: sessionId, agent: agentId });
12972
13062
  hub.invoke('session.cost', sessionId ? { session: sessionId } : {});
12973
13063
  }
12974
13064
 
@@ -15025,6 +15115,7 @@ function terminalShortcut(e, probe = false) {
15025
15115
  if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
15026
15116
  if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
15027
15117
  if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
15118
+ if (ctrlShift && e.code === 'Period') return currentSessionId ? () => toggleSessionSticky(currentSessionId) : null;
15028
15119
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
15029
15120
  return () => openNewSession(null, e.code === 'KeyR');
15030
15121
  }
@@ -15345,6 +15436,7 @@ const TERMINAL_PROBE_KEYS = [
15345
15436
  ['Backquote', '`', '~'],
15346
15437
  ['Equal', '=', '+'],
15347
15438
  ['Minus', '-', '_'],
15439
+ ['Period', '.', '>'],
15348
15440
  ['NumpadAdd', '+', '+'],
15349
15441
  ['NumpadSubtract', '-', '-'],
15350
15442
  ['Numpad0', '0', '0'],
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;">
@@ -885,12 +885,18 @@
885
885
 
886
886
  <div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
887
887
  <div class="modal session-picker" onclick="event.stopPropagation()">
888
- <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
888
+ <div class="sp-head">
889
+ <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
890
+ <div id="session-picker-scope" class="sp-scope" role="radiogroup" aria-label="Sessions" title="Tab switches scope">
891
+ <button type="button" role="radio" tabindex="-1" data-scope="board">Board</button>
892
+ <button type="button" role="radio" tabindex="-1" data-scope="all">All</button>
893
+ </div>
894
+ </div>
889
895
  <div class="sp-body">
890
896
  <div id="session-picker-list" class="sp-list"></div>
891
897
  <div id="session-picker-peek" class="sp-peek"></div>
892
898
  </div>
893
- <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
899
+ <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Tab</kbd> board / all &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
894
900
  </div>
895
901
  </div>
896
902
 
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 {
@@ -8742,6 +8746,54 @@ select.form-input option:checked {
8742
8746
  overflow: hidden;
8743
8747
  }
8744
8748
 
8749
+ .sp-head {
8750
+ display: flex;
8751
+ align-items: center;
8752
+ gap: 8px;
8753
+ }
8754
+
8755
+ .sp-head .sp-input {
8756
+ flex: 1;
8757
+ }
8758
+
8759
+ .sp-scope {
8760
+ flex: none;
8761
+ display: flex;
8762
+ gap: 2px;
8763
+ padding: 2px;
8764
+ border: 1px solid var(--border);
8765
+ border-radius: 6px;
8766
+ }
8767
+
8768
+ .sp-scope button {
8769
+ padding: 4px 10px;
8770
+ background: none;
8771
+ border: none;
8772
+ border-radius: 4px;
8773
+ font-family: var(--mono);
8774
+ font-size: 11.5px;
8775
+ color: var(--text-muted);
8776
+ cursor: pointer;
8777
+ }
8778
+
8779
+ .sp-scope button:hover {
8780
+ color: var(--text-secondary);
8781
+ }
8782
+
8783
+ .sp-scope button[aria-checked='true'] {
8784
+ background: var(--bg-deep);
8785
+ color: var(--text-primary);
8786
+ }
8787
+
8788
+ .sp-section {
8789
+ margin-top: 8px;
8790
+ padding: 2px 8px;
8791
+ color: var(--text-muted);
8792
+ font-size: 10.5px;
8793
+ letter-spacing: 0.04em;
8794
+ text-transform: uppercase;
8795
+ }
8796
+
8745
8797
  .sp-input {
8746
8798
  padding: 10px 12px;
8747
8799
  background: var(--bg-deep);
@@ -8905,6 +8957,10 @@ select.form-input option:checked {
8905
8957
  margin-top: 8px;
8906
8958
  }
8907
8959
 
8960
+ .sp-section + .sp-row.outside {
8961
+ margin-top: 0;
8962
+ }
8963
+
8908
8964
  .sp-row.current .sp-name {
8909
8965
  color: var(--accent-text);
8910
8966
  }
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 });