@north-light/crouter 0.3.158 → 0.3.160

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/api/dto/nodes.d.ts +3 -0
  2. package/dist/builtin-memory/internal/INDEX.md +1 -0
  3. package/dist/builtin-memory/internal/storage-tiers.md +2 -0
  4. package/dist/builtin-memory/internal/workflow-codification.md +82 -0
  5. package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
  7. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
  8. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
  9. package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -8
  10. package/dist/clients/attach/chrome/bash-jobs.js +20 -32
  11. package/dist/clients/attach/chrome/canvas-panels.js +10 -3
  12. package/dist/clients/attach/chrome/roster.d.ts +10 -4
  13. package/dist/clients/attach/chrome/roster.js +96 -36
  14. package/dist/clients/attach/input/controller.d.ts +3 -0
  15. package/dist/clients/attach/input/controller.js +1 -0
  16. package/dist/clients/attach/render/assistant-message.d.ts +13 -0
  17. package/dist/clients/attach/render/assistant-message.js +55 -0
  18. package/dist/clients/attach/render/chat-view.d.ts +29 -0
  19. package/dist/clients/attach/render/chat-view.js +233 -12
  20. package/dist/clients/attach/render/context-message.js +1 -1
  21. package/dist/clients/attach/render/edit-diff.js +20 -1
  22. package/dist/clients/attach/render/tool-calls.d.ts +32 -0
  23. package/dist/clients/attach/render/tool-calls.js +251 -0
  24. package/dist/clients/attach/session/input-wiring.d.ts +3 -0
  25. package/dist/clients/attach/session/input-wiring.js +3 -0
  26. package/dist/clients/attach/slash/dispatch.d.ts +4 -0
  27. package/dist/clients/attach/slash/dispatch.js +8 -0
  28. package/dist/clients/attach/viewer.js +568 -566
  29. package/dist/commands/cron.js +2 -2
  30. package/dist/commands/node.js +141 -3
  31. package/dist/commands/search.js +1 -1
  32. package/dist/commands/surface-inspect.js +4 -3
  33. package/dist/commands/sys/config.js +10 -2
  34. package/dist/commands/sys/doctor.js +22 -1
  35. package/dist/commands/sys/setup-core.d.ts +5 -1
  36. package/dist/commands/sys/setup-core.js +16 -2
  37. package/dist/commands/sys/setup-wizard.d.ts +11 -0
  38. package/dist/commands/sys/setup-wizard.js +111 -6
  39. package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
  40. package/dist/core/__tests__/tmux-surface.test.js +5 -2
  41. package/dist/core/bash-jobs.d.ts +11 -0
  42. package/dist/core/bash-jobs.js +41 -1
  43. package/dist/core/canvas/canvas.js +1 -1
  44. package/dist/core/canvas/labels.d.ts +1 -0
  45. package/dist/core/canvas/labels.js +3 -1
  46. package/dist/core/canvas/nav-render.d.ts +6 -0
  47. package/dist/core/canvas/nav-render.js +8 -0
  48. package/dist/core/canvas/types.d.ts +6 -0
  49. package/dist/core/config.js +3 -2
  50. package/dist/core/feed/inbox.d.ts +3 -3
  51. package/dist/core/feed/inbox.js +10 -6
  52. package/dist/core/inspector/core.d.ts +17 -2
  53. package/dist/core/inspector/core.js +172 -23
  54. package/dist/core/inspector/model.d.ts +30 -1
  55. package/dist/core/inspector/model.js +39 -0
  56. package/dist/core/inspector/text.js +14 -1
  57. package/dist/core/inspector/tui.js +71 -3
  58. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  59. package/dist/core/keybindings/catalog.d.ts +3 -3
  60. package/dist/core/keybindings/catalog.js +5 -2
  61. package/dist/core/preview-registry.d.ts +5 -0
  62. package/dist/core/preview-registry.js +10 -0
  63. package/dist/core/runtime/canvas-extensions.d.ts +4 -0
  64. package/dist/core/runtime/canvas-extensions.js +4 -0
  65. package/dist/core/runtime/naming-persist.d.ts +4 -3
  66. package/dist/core/runtime/naming-persist.js +10 -9
  67. package/dist/core/runtime/naming.d.ts +28 -8
  68. package/dist/core/runtime/naming.js +168 -32
  69. package/dist/core/runtime/nerd-font.d.ts +28 -0
  70. package/dist/core/runtime/nerd-font.js +127 -0
  71. package/dist/core/runtime/tmux.js +16 -0
  72. package/dist/core/tui/host.js +21 -6
  73. package/dist/daemon/api/map.js +2 -0
  74. package/dist/pi-extensions/canvas-bash-valve.js +5 -0
  75. package/dist/pi-extensions/naming-tool.d.ts +24 -0
  76. package/dist/pi-extensions/naming-tool.js +67 -0
  77. package/dist/types.d.ts +5 -0
  78. package/dist/types.js +1 -0
  79. package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
  80. package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
  81. package/dist/web-client/index.html +2 -2
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +1 -1
  84. package/runtime.lock.json +2 -2
  85. package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
@@ -6,6 +6,12 @@
6
6
  // the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
7
7
  // meta so it survives revives and updates the live session name.
8
8
  //
9
+ // The namer returns a STRUCTURED result — {name, icon} — produced by constrained
10
+ // sampling against the `name_session` tool in `pi-extensions/naming-tool.ts`,
11
+ // not by parsing prose. The icon is a Nerd Font glyph the model picks freely
12
+ // (guided by example blocks, not a closed enum) and is stored as its own meta
13
+ // field, so each surface decides whether to render it.
14
+ //
9
15
  // One entry point: generateAndPersistName — async (execFile, non-blocking).
10
16
  // Naming happens INSIDE the named node's own pi process, off the first real
11
17
  // message (the kickoff task or a human's first line), never on the spawn path:
@@ -16,23 +22,59 @@
16
22
  // Best-effort: a failed/slow/garbled pi call falls back to a local slug of the
17
23
  // prompt, so a node always gets a sane name.
18
24
  import { execFile } from 'node:child_process';
25
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join } from 'node:path';
19
28
  import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
29
+ import { NAMING_TOOL_PATH } from './canvas-extensions.js';
30
+ import { defaultProvider, modelLadders, stripThinkingSuffix } from './model-selection.js';
20
31
  /** Cap on prompt text fed to the namer — a name needs only the gist. */
21
32
  const PROMPT_CAP = 2000;
22
- /** Wall-clock budget for the headless pi call before we fall back to a slug. */
23
- const NAME_TIMEOUT_MS = 20_000;
24
- const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. This name is a label used to identify the ' +
33
+ /** Wall-clock budget for the headless pi call before we fall back to a slug.
34
+ * Generous because the structured path costs a tool round-trip on top of the
35
+ * provider's own cold start; naming is async and off the spawn path, so a slow
36
+ * call delays only the label, never the node. */
37
+ const NAME_TIMEOUT_MS = 45_000;
38
+ /** Nerd Font glyphs offered to the namer as EXAMPLES, never as a closed set —
39
+ * the model may pick any Nerd Font character it thinks fits better. Grouped by
40
+ * the kind of work they read as, so the model generalizes from the grouping
41
+ * instead of pattern-matching a lookup table. */
42
+ const ICON_EXAMPLES = [
43
+ 'fixing/diagnosis: U+F188 bug, U+F0F1 stethoscope, U+F06D fire, U+F071 warning',
44
+ 'building/changing code: U+F0AD wrench, U+F067 plus, U+F121 code, U+F085 cogs, U+F021 refresh',
45
+ 'testing/verifying: U+F0C3 flask, U+F00C check, U+F24E balance',
46
+ 'writing/docs/research: U+F02D book, U+F15C document, U+F002 magnifier, U+F0EB lightbulb',
47
+ 'data/infra: U+F1C0 database, U+F0C2 cloud, U+F120 terminal, U+F013 gear, U+F1B3 packages',
48
+ 'ship/perf/scale: U+F135 rocket, U+F0E7 bolt, U+F080 chart, U+F201 trend line',
49
+ 'security/access: U+F132 shield, U+F023 lock, U+F084 key',
50
+ 'interface/design: U+F1FC paintbrush, U+F03E image, U+F108 display',
51
+ 'communication/coordination: U+F1D8 paper plane, U+F0E6 comments, U+F0E8 sitemap, U+F0C1 link',
52
+ ].join('\n');
53
+ const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a label used to identify the ' +
25
54
  'session at a glance among many other concurrent programming sessions, so it must ' +
26
- 'describe what the task is about. Reply with ONLY a concise 3-8 word name in ' +
27
- 'kebab-case: lowercase words joined by single hyphens (e.g. `refactor-auth-token-flow`, ' +
28
- '`add-csv-export-endpoint`). No punctuation, quotes, prose, or trailing text. ' +
29
- 'Output JUST the name, nothing else.';
55
+ 'describe what the task is about.\n\n' +
56
+ 'Call the `name_session` tool exactly once with:\n' +
57
+ '- `name`: a concise 3-8 word kebab-case name — lowercase words joined by single ' +
58
+ 'hyphens (e.g. `refactor-auth-token-flow`, `add-csv-export-endpoint`). No punctuation, ' +
59
+ 'quotes, or prose.\n' +
60
+ '- `icon`: ONE Nerd Font glyph, written as its codepoint in `U+XXXX` form (e.g. ' +
61
+ '`U+F188`). ANY Nerd Font codepoint is allowed — the whole Font Awesome, Material, ' +
62
+ 'Devicons, Octicons and Codicons ranges are available, so reach for the glyph that ' +
63
+ 'actually depicts this work rather than settling for one of the examples below. ' +
64
+ 'Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
65
+ 'Pick the glyph whose depicted object a person would associate with this work at a ' +
66
+ 'glance. Prefer the concrete subject (a database, a lock, a browser, a specific ' +
67
+ "technology's logo) over a generic verb glyph when the task has one; fall back to the " +
68
+ 'activity (fixing, building, testing) otherwise. Never reuse a generic gear for ' +
69
+ 'everything.\n\n' +
70
+ 'Examples of what reads well, by kind of work:\n' +
71
+ ICON_EXAMPLES;
30
72
  /** Put the raw task text FIRST in a delimited block, then the instruction, so the
31
73
  * model reads the content before being told what to do and never mistakes the
32
74
  * prompt's own text for the instruction. The prompt is capped first, so the
33
75
  * closing tag is always present. */
34
76
  function nameUserPrompt(prompt) {
35
- return `<prompt>\n${prompt.slice(0, PROMPT_CAP)}\n</prompt>\n\nName this session based on the task above. The name should describe what the task is about, so it can be identified among many other programming sessions. Output JUST the name, nothing else.`;
77
+ return `<prompt>\n${prompt.slice(0, PROMPT_CAP)}\n</prompt>\n\nName this session based on the task above, and pick an icon depicting it. Call \`name_session\` once, then stop.`;
36
78
  }
37
79
  /** A short stop-word set so the local-slug fallback skips filler words. */
38
80
  const STOPWORDS = new Set([
@@ -64,18 +106,24 @@ export function slugFromPrompt(prompt) {
64
106
  .slice(0, 3);
65
107
  return sanitizeSessionName(picked.join('-')) || 'session';
66
108
  }
67
- /** Default namer model — Anthropic's small/fast model. Naming is a one-line
68
- * classification, so we pin Haiku (cheap, quick) instead of inheriting the
69
- * node's heavyweight default. Override with CRTR_NAME_MODEL. */
70
- const DEFAULT_NAME_MODEL = 'anthropic/claude-haiku-4-5';
71
- /** The pi argv for a headless name request. Stripped down (no tools, session,
72
- * context files, extensions, skills, templates, themes) so it's fast and
73
- * side-effect free. Pinned to Haiku with thinking off — naming is a trivial
74
- * classification that never needs a reasoning budget. Override the model with
75
- * CRTR_NAME_MODEL. */
76
- function piTextArgs(systemPrompt, userPrompt) {
109
+ /** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
110
+ * any thinking suffix stripped (we pass `--thinking off` explicitly — naming is
111
+ * a trivial classification that never needs a reasoning budget). Reading the
112
+ * ladder rather than pinning a concrete model id keeps naming working for a
113
+ * user who never configured Anthropic: an OpenAI default provider resolves to
114
+ * its own light rung. `CRTR_NAME_MODEL` still overrides. */
115
+ function nameModel() {
77
116
  const override = process.env['CRTR_NAME_MODEL'];
78
- const model = override !== undefined && override.trim() !== '' ? override.trim() : DEFAULT_NAME_MODEL;
117
+ if (override !== undefined && override.trim() !== '')
118
+ return override.trim();
119
+ const ladders = modelLadders();
120
+ return stripThinkingSuffix(ladders[defaultProvider(ladders)].light);
121
+ }
122
+ /** The pi argv for a headless TEXT request (issue titles). Stripped down (no
123
+ * tools, session, context files, extensions, skills, templates, themes) so
124
+ * it's fast and side-effect free. */
125
+ function piTextArgs(systemPrompt, userPrompt) {
126
+ const model = nameModel();
79
127
  return [
80
128
  '-p',
81
129
  '--no-session',
@@ -93,32 +141,120 @@ function piTextArgs(systemPrompt, userPrompt) {
93
141
  userPrompt,
94
142
  ];
95
143
  }
144
+ /** The pi argv for a headless STRUCTURED name request: same stripped-down run,
145
+ * but with the naming tool loaded explicitly (`-e` still works under
146
+ * `--no-extensions`) and the tool allowlist narrowed to it, so the model's only
147
+ * possible action is one constrained-schema emission. */
96
148
  function nameArgs(prompt) {
97
- return piTextArgs(NAME_SYSTEM_PROMPT, nameUserPrompt(prompt));
149
+ return [
150
+ '-p',
151
+ '--no-session',
152
+ '--no-context-files',
153
+ '--no-extensions',
154
+ '--no-skills',
155
+ '--no-prompt-templates',
156
+ '--no-themes',
157
+ '-e', NAMING_TOOL_PATH,
158
+ '--tools', 'name_session',
159
+ '--mode', 'text',
160
+ // A trivial one-shot classification — no thinking budget, ever.
161
+ '--thinking', 'off',
162
+ '--model', nameModel(),
163
+ '--system-prompt', NAME_SYSTEM_PROMPT,
164
+ nameUserPrompt(prompt),
165
+ ];
98
166
  }
99
- /** Ask pi headlessly for a kebab-case name for `body`, async. Resolves to the
100
- * sanitized name, or '' on any failure (non-zero exit, timeout, empty/garbled
101
- * output) so the caller can fall back to a local slug. Owns the subprocess
102
- * mechanics — crucially it hands pi an immediate stdin EOF: `pi -p` reads
103
- * stdin, and execFile's default stdin is an OPEN pipe that never closes, so
104
- * without this pi blocks waiting for EOF and the call exits non-zero (the
105
- * regression that silently lost every LLM name to the slug fallback).
167
+ /** Normalize a model-supplied icon to a single renderable glyph, or '' when
168
+ * nothing usable came back.
169
+ *
170
+ * The model is asked for a `U+XXXX` codepoint rather than the literal
171
+ * character: Nerd Font glyphs sit in the private-use areas, which models
172
+ * reproduce unreliably as literal text (they tend to substitute an emoji), but
173
+ * name accurately as codepoints. A literal glyph is still accepted — including
174
+ * an emoji, which renders fine even if it is not the house style — so a model
175
+ * that ignores the format still contributes. Rejects a spelled-out
176
+ * `nf-fa-bug`, a bare ASCII letter, and anything multi-glyph, all of which
177
+ * would render as noise or tofu in a label. */
178
+ export function sanitizeIcon(raw) {
179
+ const trimmed = (raw ?? '').trim();
180
+ if (trimmed === '')
181
+ return '';
182
+ const codepoint = /^(?:u\+|0x|\\u\{?|&#x)?([0-9a-f]{4,6})\}?;?$/i.exec(trimmed);
183
+ if (codepoint !== null) {
184
+ const value = Number.parseInt(codepoint[1], 16);
185
+ // Anything below the symbol blocks is a letter/digit/control — not an icon.
186
+ if (value >= 0x2000 && value <= 0x10ffff) {
187
+ try {
188
+ return String.fromCodePoint(value);
189
+ }
190
+ catch {
191
+ return '';
192
+ }
193
+ }
194
+ return '';
195
+ }
196
+ const points = [...trimmed];
197
+ // A glyph, plus at most a variation selector.
198
+ if (points.length > 2)
199
+ return '';
200
+ return points[0].codePointAt(0) >= 0x2000 ? trimmed : '';
201
+ }
202
+ /** Ask pi headlessly for a structured {name, icon} for `body`, async. Resolves
203
+ * to a sanitized SessionName, or `{name:'',icon:''}` on any failure (non-zero
204
+ * exit, timeout, no tool call, garbled emission) so the caller can fall back to
205
+ * a local slug. Owns the subprocess mechanics — crucially it hands pi an
206
+ * immediate stdin EOF: `pi -p` reads stdin, and execFile's default stdin is an
207
+ * OPEN pipe that never closes, so without this pi blocks waiting for EOF and
208
+ * the call exits non-zero (the regression that silently lost every LLM name to
209
+ * the slug fallback).
106
210
  *
107
211
  * Exported (not just for tests): the canvas-coupled persistence wrapper
108
212
  * generateAndPersistName lives in naming-persist.ts to keep this module
109
213
  * canvas-free, and imports this pure helper. */
110
214
  export function headlessName(body) {
215
+ const empty = { name: '', icon: '' };
111
216
  return new Promise((resolve) => {
217
+ let dir = null;
112
218
  try {
113
- const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], { encoding: 'utf8', timeout: NAME_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
114
- if (err || typeof stdout !== 'string')
115
- return resolve('');
116
- resolve(sanitizeSessionName(stdout));
219
+ dir = mkdtempSync(join(tmpdir(), 'crtr-name-'));
220
+ const resultFile = join(dir, 'name.json');
221
+ const done = (value) => {
222
+ if (dir !== null) {
223
+ try {
224
+ rmSync(dir, { recursive: true, force: true });
225
+ }
226
+ catch { /* temp */ }
227
+ }
228
+ resolve(value);
229
+ };
230
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], {
231
+ encoding: 'utf8',
232
+ timeout: NAME_TIMEOUT_MS,
233
+ env: { ...bundledPiSubprocessEnv(), CRTR_NAME_RESULT_FILE: resultFile },
234
+ }, () => {
235
+ // The tool call is the ONLY channel: a non-zero exit still counts when
236
+ // the emission already landed (pi print-mode can exit oddly after a
237
+ // terminating tool), and a clean exit without the file is a failure.
238
+ try {
239
+ const parsed = JSON.parse(readFileSync(resultFile, 'utf8'));
240
+ const name = sanitizeSessionName(typeof parsed['name'] === 'string' ? parsed['name'] : '');
241
+ const icon = sanitizeIcon(typeof parsed['icon'] === 'string' ? parsed['icon'] : '');
242
+ done(name !== '' ? { name, icon } : empty);
243
+ }
244
+ catch {
245
+ done(empty);
246
+ }
117
247
  });
118
248
  child.stdin?.end(); // immediate EOF — see the doc above
119
249
  }
120
250
  catch {
121
- resolve('');
251
+ if (dir !== null) {
252
+ try {
253
+ rmSync(dir, { recursive: true, force: true });
254
+ }
255
+ catch { /* temp */ }
256
+ }
257
+ resolve(empty);
122
258
  }
123
259
  });
124
260
  }
@@ -0,0 +1,28 @@
1
+ export interface NerdFontDetection {
2
+ /** A Nerd Font face is installed somewhere on this system. */
3
+ installed: boolean;
4
+ /** The family or filename that matched, for the doctor message. */
5
+ evidence: string | null;
6
+ /** How the match was found — fontconfig query, or a font-directory scan. */
7
+ source: 'fc-list' | 'font-dir' | null;
8
+ }
9
+ export declare function detectNerdFont(platform?: NodeJS.Platform, home?: string): NerdFontDetection;
10
+ /** Download the upstream symbols-only patch into the user's font dir. Works on
11
+ * every Linux regardless of package manager — Debian/Ubuntu and Fedora ship no
12
+ * Nerd Font package, so there is nothing native to install there. Needs no
13
+ * sudo: it writes to `~/.local/share/fonts`. */
14
+ export declare const NERD_FONT_MANUAL_LINUX_COMMAND: string;
15
+ export interface NerdFontInstallCommand {
16
+ command: string | null;
17
+ hint: string;
18
+ autoInstall: boolean;
19
+ }
20
+ /** The install command for this platform/package manager. `packageManager`
21
+ * matches setup's detection: only brew and pacman have a real Nerd Font
22
+ * package, everything else on Linux gets the upstream download. A non-brew
23
+ * package manager only ever exists on Linux, so it settles the platform on its
24
+ * own and `platform` decides only the `manual` case. */
25
+ export declare function nerdFontInstallCommand(packageManager: 'brew' | 'apt-get' | 'dnf' | 'pacman' | 'manual', platform?: NodeJS.Platform): NerdFontInstallCommand;
26
+ /** Every install path ends the same way: the font is on disk but the terminal
27
+ * emulator still has to be pointed at it, and crtr cannot do that. */
28
+ export declare const NERD_FONT_SELECT_HINT = "then set your terminal profile's font to a Nerd Font (e.g. add \"Symbols Nerd Font Mono\" as the non-ASCII/fallback font) \u2014 installing it does not select it";
@@ -0,0 +1,127 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /** crtr's viewer renders tool calls, command icons, node badges and the
6
+ * inspector with Nerd Font private-use-area codepoints (see
7
+ * `core/preview-registry.ts` and `clients/attach/render/tool-calls.ts`), so a
8
+ * terminal without a Nerd Font shows tofu boxes for every icon. Nothing about
9
+ * the font is auto-detectable from inside the process — the terminal emulator
10
+ * picks the face — so the best we can do is detect an INSTALLED Nerd Font on
11
+ * the system and tell the user to point their terminal profile at it. */
12
+ /** Font-family and filename fragments that only appear in Nerd Font builds.
13
+ * `symbols` alone would false-positive on system symbol faces, so the
14
+ * symbols-only patch is matched with its full distributed name. */
15
+ const NERD_FONT_PATTERN = /nerd[ _-]?font|symbolsnerdfont|symbols-only/i;
16
+ /** Font directories searched when fontconfig is unavailable (always the case
17
+ * on macOS). User-level dirs first: an install crtr suggested lands there. */
18
+ function fontDirs(platform, home) {
19
+ if (platform === 'darwin') {
20
+ return [join(home, 'Library', 'Fonts'), '/Library/Fonts', '/System/Library/Fonts'];
21
+ }
22
+ return [
23
+ join(home, '.local', 'share', 'fonts'),
24
+ join(home, '.fonts'),
25
+ '/usr/local/share/fonts',
26
+ '/usr/share/fonts',
27
+ ];
28
+ }
29
+ /** Shallow recursive filename scan. Font trees nest a couple of levels
30
+ * (`/usr/share/fonts/truetype/<family>/`), so depth 3 reaches every real
31
+ * install without walking an unbounded tree. */
32
+ function scanForNerdFont(dir, depth) {
33
+ if (depth < 0)
34
+ return null;
35
+ let entries;
36
+ try {
37
+ entries = readdirSync(dir, { withFileTypes: true });
38
+ }
39
+ catch {
40
+ return null; // absent or unreadable directory is simply not a match
41
+ }
42
+ for (const entry of entries) {
43
+ if (entry.isDirectory()) {
44
+ const nested = scanForNerdFont(join(dir, entry.name), depth - 1);
45
+ if (nested !== null)
46
+ return nested;
47
+ continue;
48
+ }
49
+ if (!/\.(ttf|otf|ttc)$/i.test(entry.name))
50
+ continue;
51
+ if (NERD_FONT_PATTERN.test(entry.name))
52
+ return entry.name;
53
+ }
54
+ return null;
55
+ }
56
+ /** Ask fontconfig for installed families. Present on Linux, absent on macOS. */
57
+ function queryFontconfig() {
58
+ const probe = spawnSync('sh', ['-lc', 'command -v fc-list >/dev/null 2>&1'], { stdio: 'ignore' });
59
+ if (probe.status !== 0)
60
+ return null;
61
+ const res = spawnSync('fc-list', [':', 'family'], { encoding: 'utf8', timeout: 5000 });
62
+ if (res.status !== 0 || typeof res.stdout !== 'string')
63
+ return null;
64
+ for (const line of res.stdout.split('\n')) {
65
+ if (NERD_FONT_PATTERN.test(line))
66
+ return line.split(',')[0].trim();
67
+ }
68
+ return ''; // fontconfig answered and knows of no Nerd Font — authoritative miss
69
+ }
70
+ export function detectNerdFont(platform = process.platform, home = homedir()) {
71
+ if (platform !== 'darwin') {
72
+ const family = queryFontconfig();
73
+ if (family !== null && family !== '')
74
+ return { installed: true, evidence: family, source: 'fc-list' };
75
+ }
76
+ for (const dir of fontDirs(platform, home)) {
77
+ const file = scanForNerdFont(dir, 3);
78
+ if (file !== null)
79
+ return { installed: true, evidence: file, source: 'font-dir' };
80
+ }
81
+ return { installed: false, evidence: null, source: null };
82
+ }
83
+ /** Download the upstream symbols-only patch into the user's font dir. Works on
84
+ * every Linux regardless of package manager — Debian/Ubuntu and Fedora ship no
85
+ * Nerd Font package, so there is nothing native to install there. Needs no
86
+ * sudo: it writes to `~/.local/share/fonts`. */
87
+ export const NERD_FONT_MANUAL_LINUX_COMMAND = 'mkdir -p ~/.local/share/fonts && '
88
+ + 'curl -fsSL -o /tmp/NerdFontsSymbolsOnly.zip '
89
+ + 'https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip && '
90
+ + 'unzip -oq /tmp/NerdFontsSymbolsOnly.zip -d ~/.local/share/fonts && fc-cache -f';
91
+ /** The install command for this platform/package manager. `packageManager`
92
+ * matches setup's detection: only brew and pacman have a real Nerd Font
93
+ * package, everything else on Linux gets the upstream download. A non-brew
94
+ * package manager only ever exists on Linux, so it settles the platform on its
95
+ * own and `platform` decides only the `manual` case. */
96
+ export function nerdFontInstallCommand(packageManager, platform = process.platform) {
97
+ const onLinux = platform !== 'darwin' || packageManager === 'apt-get' || packageManager === 'dnf' || packageManager === 'pacman';
98
+ if (packageManager === 'brew') {
99
+ return {
100
+ command: 'brew install --cask font-symbols-only-nerd-font',
101
+ hint: 'Homebrew cask',
102
+ autoInstall: true,
103
+ };
104
+ }
105
+ if (packageManager === 'pacman') {
106
+ return {
107
+ command: 'sudo pacman -S --noconfirm ttf-nerd-fonts-symbols',
108
+ hint: 'pacman install',
109
+ autoInstall: true,
110
+ };
111
+ }
112
+ if (onLinux) {
113
+ return {
114
+ command: NERD_FONT_MANUAL_LINUX_COMMAND,
115
+ hint: 'download symbols-only patch to ~/.local/share/fonts (needs curl + unzip)',
116
+ autoInstall: true,
117
+ };
118
+ }
119
+ return {
120
+ command: null,
121
+ hint: 'install Homebrew, or download a Nerd Font from https://nerdfonts.com and add it in Font Book',
122
+ autoInstall: false,
123
+ };
124
+ }
125
+ /** Every install path ends the same way: the font is on disk but the terminal
126
+ * emulator still has to be pointed at it, and crtr cannot do that. */
127
+ export const NERD_FONT_SELECT_HINT = 'then set your terminal profile\'s font to a Nerd Font (e.g. add "Symbols Nerd Font Mono" as the non-ASCII/fallback font) — installing it does not select it';
@@ -483,6 +483,8 @@ const MENU_ACTIONS = {
483
483
  // M-S-r carries shift+alt and lands as the same chord the keyboard produces.
484
484
  'crtr.tmux.menu.file-review': { description: 'review a file', action: { kind: 'raw-keys', keys: 'M-S-r' } },
485
485
  'crtr.tmux.menu.metadata': { description: 'metadata', action: { kind: 'keys', keys: '/node-metadata' } },
486
+ 'crtr.tmux.menu.fold-tools': { description: 'fold finished tool calls', action: { kind: 'keys', keys: '/fold-tools' } },
487
+ 'crtr.tmux.menu.view-settings': undefined,
486
488
  'crtr.tmux.menu.context': { description: 'context + reports', action: { kind: 'keys', keys: '/context' } },
487
489
  'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys setup' } },
488
490
  'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
@@ -627,6 +629,11 @@ function menuItems(bindings) {
627
629
  'crtr.tmux.menu.metadata',
628
630
  'crtr.tmux.menu.context',
629
631
  ]);
632
+ // How this viewer PAINTS the transcript, as opposed to what it shows. Each item
633
+ // is a toggle the viewer applies to its own render state (a `/`-command sent to
634
+ // the pane); a tmux menu is built once and cannot show live state, so the item
635
+ // reads as the action and the viewer answers with a notice.
636
+ const viewSettingsItems = boundItems(['crtr.tmux.menu.fold-tools']);
630
637
  const exploreItems = boundItems([
631
638
  'crtr.tmux.menu.resume',
632
639
  'crtr.tmux.menu.graph',
@@ -641,6 +648,15 @@ function menuItems(bindings) {
641
648
  });
642
649
  }
643
650
  }
651
+ if (viewSettingsItems.length > 0) {
652
+ for (const gesture of bindings.gestures('crtr.tmux.menu.view-settings')) {
653
+ exploreItems.push({
654
+ description: 'view settings ›',
655
+ selector: menuSelector(gesture),
656
+ action: { kind: 'submenu', title: ' view settings ', items: viewSettingsItems },
657
+ });
658
+ }
659
+ }
644
660
  // Explore changes what is in view or opens a canvas-level browser. Current
645
661
  // node acts on the agent in this pane without changing its lifecycle.
646
662
  addGroup('Explore', exploreItems);
@@ -501,9 +501,12 @@ export async function runCoreView(core, tui, text, opts = {}) {
501
501
  setMode(mode) { chrome.mode = mode; },
502
502
  quit() { finish(); },
503
503
  };
504
- // The single-flight busy lane lives in the external FIFO. Key input still
505
- // drops while busy; refresh timers still skip while busy; ctx.dispatch from
506
- // inside an intent runs inline so chained intents do not deadlock.
504
+ // The single-flight busy lane lives in the external FIFO. Refresh timers skip
505
+ // while busy; ctx.dispatch from inside an intent runs inline so chained intents
506
+ // do not deadlock. Keystrokes QUEUE behind an in-flight intent (up to a small
507
+ // depth) rather than vanishing: a view whose auto-refresh shells out is busy a
508
+ // real fraction of the time, and silently swallowing a `y` confirmation or a
509
+ // cursor move is the difference between a live surface and a deaf one.
507
510
  const streams = new Set();
508
511
  let busy = false;
509
512
  let dispatching = false;
@@ -627,6 +630,9 @@ export async function runCoreView(core, tui, text, opts = {}) {
627
630
  render();
628
631
  if (core.intents['refresh'])
629
632
  await enqueueDispatch('refresh');
633
+ // Keystroke-dispatch backlog depth (see onData).
634
+ const MAX_QUEUED_KEYS = 2;
635
+ let queuedKeys = 0;
630
636
  // Capture-mode buffer: the host's line-editor draft while a `capture` binding's
631
637
  // when(state) is true; reset to '' whenever no capture binding is active.
632
638
  let captureBuf = '';
@@ -650,8 +656,10 @@ export async function runCoreView(core, tui, text, opts = {}) {
650
656
  finish();
651
657
  return;
652
658
  }
653
- // Drop keystrokes while an async intent is in flight (paces fetch/send).
654
- if (busy)
659
+ // Queue behind an in-flight intent, but shallowly: past a couple of pending
660
+ // keys the user is hammering a stuck view, and replaying the backlog later
661
+ // would act on a screen they never saw.
662
+ if (busy && queuedKeys >= MAX_QUEUED_KEYS)
655
663
  return;
656
664
  // Text-capture: while a capture binding is active, printable/backspace edit
657
665
  // the host buffer and dispatch capture(nextDraft); other keys (return/escape)
@@ -673,8 +681,15 @@ export async function runCoreView(core, tui, text, opts = {}) {
673
681
  captureBuf = '';
674
682
  }
675
683
  const m = matchViewBinding(tui.keymap, bindings, input, key, state, raw);
676
- if (m)
684
+ if (!m)
685
+ return;
686
+ queuedKeys += 1;
687
+ try {
677
688
  await enqueueDispatch(m.intent, m.payload);
689
+ }
690
+ finally {
691
+ queuedKeys -= 1;
692
+ }
678
693
  };
679
694
  process.stdin.on('data', (d) => { void onData(d); });
680
695
  // Resize → repaint from current state (never re-enter an in-flight intent).
@@ -105,6 +105,8 @@ export function toNodeDetailDTO(meta, edges) {
105
105
  };
106
106
  if (meta.description !== undefined)
107
107
  dto.description = meta.description;
108
+ if (meta.icon !== undefined)
109
+ dto.icon = meta.icon;
108
110
  if (meta.cycles !== undefined)
109
111
  dto.cycles = meta.cycles;
110
112
  dto.context_tokens = readContextTokens(meta.node_id);
@@ -244,6 +244,11 @@ export function createValveOperations(nodeId, contextDir) {
244
244
  finish(() => reject(err));
245
245
  });
246
246
  pgid = child.pid;
247
+ // The detached supervisor IS the group leader, so its pid is the pgid.
248
+ // Persisting it is what lets a human stop the job from the Inspector
249
+ // later; the agent's own handoff notice quotes the same number.
250
+ if (pgid !== undefined)
251
+ writeFileSync(paths.jobPgid, String(pgid));
247
252
  child.unref();
248
253
  interval = setInterval(() => {
249
254
  offset = tailOnce(paths.jobLog, offset, onData);
@@ -0,0 +1,24 @@
1
+ interface PiLike {
2
+ registerTool: (definition: Record<string, unknown>) => void;
3
+ }
4
+ /** The naming schema — the ONE definition of what a generated session name is.
5
+ * Both fields are required: a name without an icon renders a bare label, and
6
+ * an icon without a name is useless, so the model is made to decide both in a
7
+ * single constrained emission. */
8
+ export declare const NAME_SESSION_SCHEMA: {
9
+ readonly type: "object";
10
+ readonly properties: {
11
+ readonly name: {
12
+ readonly type: "string";
13
+ readonly description: "A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.";
14
+ };
15
+ readonly icon: {
16
+ readonly type: "string";
17
+ readonly description: "The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).";
18
+ };
19
+ };
20
+ readonly required: readonly ["name", "icon"];
21
+ readonly additionalProperties: false;
22
+ };
23
+ export declare function registerNamingTool(pi: PiLike): void;
24
+ export default registerNamingTool;
@@ -0,0 +1,67 @@
1
+ // naming-tool.ts — the structured-output seam for session naming.
2
+ //
3
+ // Loaded ONLY by the headless namer subprocess (`runtime/naming.ts` passes it
4
+ // with `-e` alongside `--no-extensions`), never by a canvas node. It registers a
5
+ // single `name_session` tool whose parameters ARE the naming schema, with
6
+ // pi's constrained sampling turned on, so the light model emits a real typed
7
+ // object instead of prose we have to parse out of a text reply.
8
+ //
9
+ // The result travels back to the parent over a file: the parent creates a temp
10
+ // path, passes it as CRTR_NAME_RESULT_FILE, and reads it after the subprocess
11
+ // exits. stdout is unusable for this — pi print-mode writes its own chatter
12
+ // there.
13
+ //
14
+ // Inert (registers nothing) when CRTR_NAME_RESULT_FILE is absent, so an
15
+ // accidental load in any other context is a no-op.
16
+ import { mkdirSync, writeFileSync } from 'node:fs';
17
+ import { dirname } from 'node:path';
18
+ /** The naming schema — the ONE definition of what a generated session name is.
19
+ * Both fields are required: a name without an icon renders a bare label, and
20
+ * an icon without a name is useless, so the model is made to decide both in a
21
+ * single constrained emission. */
22
+ export const NAME_SESSION_SCHEMA = {
23
+ type: 'object',
24
+ properties: {
25
+ name: {
26
+ type: 'string',
27
+ description: 'A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.',
28
+ },
29
+ icon: {
30
+ type: 'string',
31
+ description: 'The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).',
32
+ },
33
+ },
34
+ required: ['name', 'icon'],
35
+ additionalProperties: false,
36
+ };
37
+ export function registerNamingTool(pi) {
38
+ const out = process.env['CRTR_NAME_RESULT_FILE'];
39
+ if (out === undefined || out.trim() === '')
40
+ return;
41
+ pi.registerTool({
42
+ name: 'name_session',
43
+ label: 'Name session',
44
+ description: 'Record the name and icon for this coding session. Call exactly once, then stop.',
45
+ parameters: NAME_SESSION_SCHEMA,
46
+ // Ask the provider to constrain sampling to the schema. `prefer` degrades
47
+ // silently on a model without strict tool support (`require` would fail the
48
+ // whole request); the parent validates and falls back to a local slug
49
+ // either way, so this only makes a malformed emission rarer.
50
+ constrainedSampling: { type: 'json_schema', strict: 'prefer' },
51
+ execute(_id, params) {
52
+ try {
53
+ mkdirSync(dirname(out), { recursive: true });
54
+ writeFileSync(out, JSON.stringify(params ?? {}), 'utf8');
55
+ }
56
+ catch {
57
+ // The parent falls back to a local slug when the file never lands.
58
+ }
59
+ return {
60
+ content: [{ type: 'text', text: 'Recorded.' }],
61
+ details: { recorded: true },
62
+ terminate: true,
63
+ };
64
+ },
65
+ });
66
+ }
67
+ export default registerNamingTool;
package/dist/types.d.ts CHANGED
@@ -152,6 +152,11 @@ export interface ScopeConfig {
152
152
  * `both` keeps both. Tool calls and results are never kept. Full detail for
153
153
  * every cycle stays in the session file. */
154
154
  condensed_history: CondensedHistoryMode;
155
+ /** Fold every SETTLED tool call in the attach viewer down to its single call
156
+ * line (`/fold-tools`, Alt+C → w → f). A tool still running is never folded —
157
+ * it folds itself once it settles. Toggling it in a viewer writes back here,
158
+ * so the choice carries into later chats and later viewers. Default false. */
159
+ fold_finished_tools: boolean;
155
160
  keybindings: KeybindingOverrides;
156
161
  modelLadders: ModelLaddersConfig;
157
162
  /** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
package/dist/types.js CHANGED
@@ -38,6 +38,7 @@ export function defaultScopeConfig() {
38
38
  completion_bell: true,
39
39
  live_cycles: DEFAULT_LIVE_CYCLES,
40
40
  condensed_history: DEFAULT_CONDENSED_HISTORY,
41
+ fold_finished_tools: false,
41
42
  keybindings: {},
42
43
  modelLadders: defaultModelLaddersConfig(),
43
44
  kinds: defaultKindsConfig(),