@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
@@ -138,7 +138,7 @@ const addLeaf = defineLeaf({
138
138
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
139
139
  { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, default: 'on-failure', constraint: 'What each run does with its output. silent: record in the run log, tell nobody — never escalate. on-failure (default): no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
140
140
  { kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
141
- { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'normal', constraint: 'Inbox priority of a node-sink delivery.' },
141
+ { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'urgent', constraint: 'Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant — a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright.' },
142
142
  { kind: 'flag', name: 'expires', type: 'string', required: false, constraint: 'Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern.' },
143
143
  { kind: 'flag', name: 'anchor', type: 'string', required: false, constraint: 'Couple the cron\'s lifetime to a node: the row is deleted when that node is deleted. Without an anchor it outlives every node; it ends through its one-shot run, --expires, explicit cancellation, or self-cancellation from its bash.' },
144
144
  { kind: 'flag', name: 'anchor-self', type: 'bool', required: false, constraint: 'Anchor to the calling node ($CRTR_NODE_ID). Errors outside a node.' },
@@ -259,7 +259,7 @@ const addLeaf = defineLeaf({
259
259
  scope,
260
260
  on_output: input['onOutput'] ?? 'on-failure',
261
261
  sink: typeof input['sink'] === 'string' && input['sink'] !== '' ? input['sink'] : null,
262
- tier: input['tier'] ?? 'normal',
262
+ tier: input['tier'] ?? 'urgent',
263
263
  expires_at: expiresAt,
264
264
  anchor_node: anchorNode,
265
265
  cancel_on_wake: cancelOnWake,
@@ -27,7 +27,8 @@ import { profilesStateBlock } from '../core/profiles/state-block.js';
27
27
  import { stateBlock } from '../core/help.js';
28
28
  import { fuzzyMatch } from '../core/canvas/browse/model.js';
29
29
  import { loadPins, savePins } from '../core/canvas/browse/pins.js';
30
- import { backgroundRunningBashJobs, formatBashElapsed, jobStartedAtMs } from '../core/bash-jobs.js';
30
+ import { existsSync, writeFileSync } from 'node:fs';
31
+ import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../core/bash-jobs.js';
31
32
  // The API seam — every canvas mutation/read routes through the daemon (B-1).
32
33
  import { cliClient, cliCanvasSource, ApiCanvasSource, rethrowAsCliError, getNodeOrNull } from './api-client.js';
33
34
  // Past this much context, an ORCHESTRATOR that spawns a managed child is better
@@ -842,16 +843,153 @@ const nodeBashBackground = defineLeaf({
842
843
  return `Handed ${result['backgrounded']} bash command(s) to the background job system for ${result['node_id']} · ran ${formatBashElapsed(oldestElapsedMs)} in the foreground.`;
843
844
  },
844
845
  });
846
+ const nodeBashList = defineLeaf({
847
+ name: 'list',
848
+ description: 'list a node’s live background bash jobs',
849
+ whenToUse: 'the Inspector’s jobs section reads this to show what a node handed off to the background',
850
+ tier: 'hidden',
851
+ help: {
852
+ name: 'node bash list',
853
+ summary: 'read every still-running background bash job owned by one node',
854
+ params: [
855
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose background jobs to list. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
856
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
857
+ { kind: 'flag', name: 'tail', type: 'int', required: false, default: 0, constraint: 'Include this many trailing log lines per job (0 = none).' },
858
+ ],
859
+ output: [
860
+ { name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
861
+ { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
862
+ ],
863
+ outputKind: 'object',
864
+ effects: ['Reads the node context jobs directory. Changes nothing.'],
865
+ },
866
+ run: async (input) => {
867
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
868
+ let id = input['node'];
869
+ if (id === undefined || id === '')
870
+ id = nodeInPane(pane);
871
+ if (id === undefined || id === '')
872
+ id = process.env['CRTR_NODE_ID'];
873
+ if (id === undefined || id === '') {
874
+ throw new InputError({ error: 'no_node', message: 'no node found for the bash job list', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
875
+ }
876
+ const node = await getNodeOrNull(id);
877
+ if (node === null) {
878
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
879
+ }
880
+ const now = Date.now();
881
+ const tail = Math.max(0, Math.trunc(Number(input['tail'] ?? 0)));
882
+ const dir = contextDir(node.node_id);
883
+ const jobs = activeBackgroundBashJobs(dir).map((job) => ({
884
+ job_id: job.jobId,
885
+ command: job.command,
886
+ started_at: new Date(job.startedAtMs).toISOString(),
887
+ elapsed_ms: now - job.startedAtMs,
888
+ log_path: job.logPath,
889
+ pgid: readJobPgid(dir, job.jobId) ?? null,
890
+ log_tail: tailJobLog(job.logPath, tail),
891
+ }));
892
+ return { node_id: node.node_id, jobs };
893
+ },
894
+ render: (result) => {
895
+ const jobs = result['jobs'];
896
+ if (jobs.length === 0)
897
+ return `No background bash jobs for ${result['node_id']}.`;
898
+ return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
899
+ },
900
+ });
901
+ /** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
902
+ * false only when the group was already gone at the first signal. */
903
+ function stopJobGroup(pgid) {
904
+ try {
905
+ process.kill(-pgid, 'SIGTERM');
906
+ }
907
+ catch {
908
+ return false;
909
+ }
910
+ return true;
911
+ }
912
+ const nodeBashKill = defineLeaf({
913
+ name: 'kill',
914
+ description: 'stop one of a node’s background bash jobs',
915
+ whenToUse: 'a human watching the Inspector or the attach cockpit’s bash row wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
916
+ tier: 'hidden',
917
+ help: {
918
+ name: 'node bash kill',
919
+ summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
920
+ params: [
921
+ { kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
922
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
923
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
924
+ ],
925
+ output: [
926
+ { name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
927
+ { name: 'job_id', type: 'string', required: true, constraint: 'The job that was stopped.' },
928
+ { name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
929
+ ],
930
+ outputKind: 'object',
931
+ effects: [
932
+ 'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
933
+ 'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
934
+ ],
935
+ },
936
+ run: async (input) => {
937
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
938
+ let id = input['node'];
939
+ if (id === undefined || id === '')
940
+ id = nodeInPane(pane);
941
+ if (id === undefined || id === '')
942
+ id = process.env['CRTR_NODE_ID'];
943
+ if (id === undefined || id === '') {
944
+ throw new InputError({ error: 'no_node', message: 'no node found for the bash job', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
945
+ }
946
+ const node = await getNodeOrNull(id);
947
+ if (node === null) {
948
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
949
+ }
950
+ const jobId = input['job'];
951
+ const dir = contextDir(node.node_id);
952
+ const paths = bashJobPaths(dir, jobId);
953
+ if (!existsSync(paths.dir)) {
954
+ throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
955
+ }
956
+ const pgid = readJobPgid(dir, jobId);
957
+ if (pgid === undefined) {
958
+ throw new InputError({
959
+ error: 'not_stoppable',
960
+ message: `job ${jobId} recorded no process group`,
961
+ next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
962
+ });
963
+ }
964
+ const signaled = stopJobGroup(pgid);
965
+ // The supervisor shares the group it leads, so it usually dies with the
966
+ // command and never writes job.exit. Record the cancellation ourselves —
967
+ // that sentinel is what retires the job from every live-jobs read.
968
+ await new Promise((r) => setTimeout(r, 400));
969
+ try {
970
+ process.kill(-pgid, 'SIGKILL');
971
+ }
972
+ catch { /* already gone */ }
973
+ if (!existsSync(paths.jobExit))
974
+ writeFileSync(paths.jobExit, '143\n');
975
+ await cliClient().sendMessage(node.node_id, {
976
+ body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
977
+ tier: 'urgent',
978
+ });
979
+ return { node_id: node.node_id, job_id: jobId, signaled };
980
+ },
981
+ render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
982
+ });
845
983
  const nodeBash = defineBranch({
846
984
  name: 'bash',
847
985
  description: 'control file-backed bash jobs owned by a node',
848
- whenToUse: 'the tmux prefix menu needs to hand a running bash command off without waiting for the automatic timeout',
986
+ whenToUse: 'a running bash command must be handed off, listed, or stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
849
987
  tier: 'hidden',
850
988
  help: {
851
989
  name: 'node bash',
852
990
  summary: 'control file-backed bash jobs owned by a node',
853
991
  },
854
- children: [nodeBashBackground],
992
+ children: [nodeBashBackground, nodeBashList, nodeBashKill],
855
993
  });
856
994
  export const surfaceNodeIdLeaf = defineLeaf({
857
995
  name: 'id',
@@ -12,7 +12,7 @@ export function registerSearch() {
12
12
  rootEntry: {
13
13
  concept: 'web search for agents — find pages, get grounded answers, extract page content (Exa)',
14
14
  desc: 'search the web, answer a question with citations, or extract content from known URLs',
15
- useWhen: 'you need information from the live web — current events, documentation, sources, facts beyond your training. Use web for relevant pages with excerpts, answer for one synthesized cited answer, and contents for clean text from URLs you already hold. Needs an Exa API key (EXA_API_KEY or ~/.crouter/exa.key).',
15
+ useWhen: 'you need information from the live web — current events, documentation, sources, facts beyond your training. Use web for relevant pages with excerpts, answer for one synthesized cited answer, and contents for clean text from URLs you already hold. This reaches PUBLIC pages read-only: for anything behind the user’s login, or any action inside a web app, drive their own browser with `crtr device`, and to call a service’s API server-side use `crtr connector`. Needs an Exa API key (EXA_API_KEY or ~/.crouter/exa.key).',
16
16
  },
17
17
  help: {
18
18
  name: 'search',
@@ -8,17 +8,18 @@ import { runCoreView } from '../core/tui/host.js';
8
8
  export const surfaceInspectLeaf = defineLeaf({
9
9
  name: 'inspect',
10
10
  description: 'host the native Inspector for one node',
11
- whenToUse: 'you want a live native screen for a node’s overview, raw record, and owner-scoped crons. Use node inspect show for the underlying one-shot data read instead.',
11
+ whenToUse: 'you want a live native screen for a node’s overview, raw record, owner-scoped crons, and live background bash jobs. Use node inspect show for the underlying one-shot data read instead.',
12
12
  help: {
13
13
  name: 'surface inspect',
14
14
  summary: 'host the native Inspector in this pane (the attach viewer opens it as a tmux popup)',
15
+ // sections: overview · schedule (armed crons) · jobs (live background bash) · raw
15
16
  params: [
16
17
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id or resolvable reference to inspect.' },
17
- { kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
18
+ { kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'jobs', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
18
19
  ],
19
20
  output: [],
20
21
  outputKind: 'object',
21
- effects: ['Reads node and cron data through crtr subprocesses and crtrd. Takes over the hosting pane (or popup) until it quits.'],
22
+ effects: ['Reads node, cron, and background bash job data through crtr subprocesses and crtrd. Takes over the hosting pane (or popup) until it quits.'],
22
23
  },
23
24
  run: async (input) => {
24
25
  const node = input['node'];
@@ -16,6 +16,7 @@ const TOP_LEVEL_KEYS = new Set([
16
16
  'completion_bell',
17
17
  'live_cycles',
18
18
  'condensed_history',
19
+ 'fold_finished_tools',
19
20
  'modelLadders',
20
21
  ]);
21
22
  const MODEL_PROVIDERS = new Set(['anthropic', 'openai']);
@@ -153,6 +154,13 @@ function setNestedValue(cfg, key, rawValue, value) {
153
154
  cfg.condensed_history = rawValue;
154
155
  return;
155
156
  }
157
+ if (key === 'fold_finished_tools') {
158
+ if (typeof value !== 'boolean') {
159
+ throw usage(`fold_finished_tools must be true or false`);
160
+ }
161
+ cfg.fold_finished_tools = value;
162
+ return;
163
+ }
156
164
  if (parts.length === 1) {
157
165
  cfg[topKey] = value;
158
166
  return;
@@ -175,7 +183,7 @@ const configGet = defineLeaf({
175
183
  name: 'sys config get',
176
184
  summary: 'read a config value by dotted key',
177
185
  params: [
178
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, brokerThresholds (user only; warning and automaticReviveCap), completion_bell, live_cycles, condensed_history, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
186
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, brokerThresholds (user only; warning and automaticReviveCap), completion_bell, live_cycles, condensed_history, fold_finished_tools, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
179
187
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
180
188
  ],
181
189
  output: [
@@ -209,7 +217,7 @@ const configSet = defineLeaf({
209
217
  name: 'sys config set',
210
218
  summary: 'write a config value by dotted key',
211
219
  params: [
212
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, brokerThresholds.warning, brokerThresholds.automaticReviveCap (user scope; positive integers), completion_bell, live_cycles (integer >= 1 — trailing cycles the attach viewer renders at full fidelity; older cycles condense to prose), condensed_history (none|user|agent|both — which messages of a condensed cycle still render; none keeps only the cycle dividers), modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
220
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, brokerThresholds.warning, brokerThresholds.automaticReviveCap (user scope; positive integers), completion_bell, live_cycles (integer >= 1 — trailing cycles the attach viewer renders at full fidelity; older cycles condense to prose), condensed_history (none|user|agent|both — which messages of a condensed cycle still render; none keeps only the cycle dividers), fold_finished_tools (true|false — fold every settled tool call in the attach viewer to its single call line), modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
213
221
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
214
222
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
215
223
  ],
@@ -10,6 +10,8 @@ import { lsRemote } from '../../core/git.js';
10
10
  import { isPidAlive } from '../../core/canvas/pid.js';
11
11
  import { cliClient } from '../api-client.js';
12
12
  import { readFault } from '../../core/runtime/fault.js';
13
+ import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
14
+ import { detectPackageManager } from './setup-core.js';
13
15
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
14
16
  import { buildExternalCommandSnapshot } from '../../core/configured-clis/discovery.js';
15
17
  import { effectiveConfiguredClis } from '../../core/configured-clis/registration.js';
@@ -277,6 +279,23 @@ async function verifySealed(root) {
277
279
  }
278
280
  await walk(root);
279
281
  }
282
+ /** The viewer draws every tool and command icon with Nerd Font codepoints, so a
283
+ * machine with no Nerd Font installed renders tofu boxes. Nothing else in
284
+ * crtr notices — this is the one place a fresh install learns why. Never
285
+ * auto-fixed: installing a font, and pointing the terminal profile at it, is a
286
+ * human action. */
287
+ function runNerdFontCheck() {
288
+ const detection = detectNerdFont();
289
+ if (detection.installed) {
290
+ return pass('user', 'terminal:nerd-font', `installed (${detection.evidence}) — if icons still render as boxes, ${NERD_FONT_SELECT_HINT}`);
291
+ }
292
+ const plan = nerdFontInstallCommand(detectPackageManager());
293
+ return failCheck('user', 'terminal:nerd-font', 'no Nerd Font installed — viewer tool/command icons will render as tofu boxes', {
294
+ kind: 'human_action',
295
+ description: `Install a Nerd Font (${plan.hint}), ${NERD_FONT_SELECT_HINT}`,
296
+ ...(plan.command ? { command: plan.command } : {}),
297
+ });
298
+ }
280
299
  async function generationChecks(label, lookup, allowAbsent) {
281
300
  if (lookup.generation === null) {
282
301
  const message = lookup.error ?? 'no runtime generation';
@@ -486,7 +505,7 @@ export const sysDoctorLeaf = defineLeaf({
486
505
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
487
506
  ],
488
507
  output: [
489
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. Command-plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
508
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Command-plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
490
509
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
491
510
  ],
492
511
  outputKind: 'object',
@@ -506,6 +525,8 @@ export const sysDoctorLeaf = defineLeaf({
506
525
  const results = runChecksForScope(scope, { fix, remote });
507
526
  allResults.push(...results);
508
527
  }
528
+ if (scopes.includes('user'))
529
+ allResults.push(runNerdFontCheck());
509
530
  allResults.push(...runCommandPluginChecks(scopes));
510
531
  allResults.push(...runConfiguredCliChecks(scopes));
511
532
  allResults.push(...await runFaultChecks());
@@ -1,6 +1,10 @@
1
1
  import { type BindingId, type BindingResolution, type KeybindingDiagnostic, type SparseKeybindingOverrides } from '../../core/keybindings/index.js';
2
2
  export type SetupPackageId = 'crtr-extensions' | 'mode-switch' | 'oauth-adapter' | 'capture-plugin';
3
- export type SetupTool = 'tmux' | 'nvim';
3
+ /** `nerd-font` is not a command on PATH like the other two — it is an installed
4
+ * font face, detected by fontconfig/font-directory scan. The viewer renders
5
+ * every tool and command icon with Nerd Font codepoints, so without it a fresh
6
+ * machine shows tofu boxes. */
7
+ export type SetupTool = 'tmux' | 'nvim' | 'nerd-font';
4
8
  /** `marketplace-plugin` installs a crtr plugin from a registered marketplace
5
9
  * (`installSource` is a `<marketplace>/<plugin>` ref) instead of running the
6
10
  * bundled pi CLI. Offered in setup and NOT installed by first-run bootstrap:
@@ -9,6 +9,7 @@ import { builtinPiPackageDir } from '../../core/scope.js';
9
9
  import { readJsonIfExists } from '../../core/fs-utils.js';
10
10
  import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
11
11
  import { installTmuxBindings } from '../../core/runtime/tmux-chrome.js';
12
+ import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
12
13
  import { globalHumanloopHealth, globalHumanloopInstallCommand } from '../../core/runtime/package-health.js';
13
14
  import { registerCrouterRoot } from '../human/shared.js';
14
15
  import { installFromMarketplace } from '../pkg/plugin-manage.js';
@@ -301,6 +302,19 @@ export function buildSystemInstallPlan(tool, packageManager, installed) {
301
302
  autoInstall: false,
302
303
  };
303
304
  }
305
+ if (tool === 'nerd-font') {
306
+ const plan = nerdFontInstallCommand(packageManager);
307
+ return {
308
+ tool,
309
+ label,
310
+ installed: false,
311
+ checked: plan.autoInstall,
312
+ packageManager,
313
+ command: plan.command,
314
+ hint: `${plan.hint} — ${NERD_FONT_SELECT_HINT}`,
315
+ autoInstall: plan.autoInstall,
316
+ };
317
+ }
304
318
  const packageName = tool === 'nvim' ? 'neovim' : 'tmux';
305
319
  switch (packageManager) {
306
320
  case 'brew':
@@ -361,7 +375,7 @@ export function buildSystemInstallPlan(tool, packageManager, installed) {
361
375
  }
362
376
  }
363
377
  export function buildSystemToolState(tool, packageManager, commandExists = detectCommandOnPath) {
364
- const installed = commandExists(tool);
378
+ const installed = tool === 'nerd-font' ? detectNerdFont().installed : commandExists(tool);
365
379
  return {
366
380
  tool,
367
381
  installed,
@@ -397,7 +411,7 @@ export function buildSetupInitialState(opts = {}) {
397
411
  installedPackageIds.add(id);
398
412
  const commandExists = opts.commandExists ?? detectCommandOnPath;
399
413
  const packageManager = opts.packageManager ?? detectPackageManager(process.platform, commandExists);
400
- const systemTools = ['tmux', 'nvim'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
414
+ const systemTools = ['tmux', 'nvim', 'nerd-font'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
401
415
  return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir) };
402
416
  }
403
417
  export function buildSetupStaticInstructions(opts) {
@@ -62,6 +62,8 @@ export declare class SetupWizard implements Component, Focusable {
62
62
  private keyNotice;
63
63
  private brokerLimitIndex;
64
64
  private brokerLimitNotice;
65
+ private displayIndex;
66
+ private displayNotice;
65
67
  private cachedWidth;
66
68
  private cachedHeight;
67
69
  private cachedLines;
@@ -74,6 +76,14 @@ export declare class SetupWizard implements Component, Focusable {
74
76
  invalidate(): void;
75
77
  private setTab;
76
78
  private moveTab;
79
+ private moveDisplayRow;
80
+ /** Advance the selected display setting to its next value and persist it to user
81
+ * config immediately — these are one-keystroke view preferences, so there is no
82
+ * separate save gesture to forget. A viewer picks the new value up on its next
83
+ * attach (`live_cycles` / `condensed_history` / `fold_finished_tools` are all read
84
+ * per ChatView), and `completion_bell` on the next turn end. */
85
+ private cycleDisplaySetting;
86
+ private handleDisplayInput;
77
87
  private cancel;
78
88
  dispose(): void;
79
89
  private matches;
@@ -124,6 +134,7 @@ export declare class SetupWizard implements Component, Focusable {
124
134
  private renderKeybindings;
125
135
  private renderModels;
126
136
  private renderBrokerLimits;
137
+ private renderDisplay;
127
138
  render(width: number): string[];
128
139
  }
129
140
  export declare function runSetup(): Promise<void>;
@@ -13,6 +13,7 @@ import { removeProviderRow } from '../../core/provider-management.js';
13
13
  import { readConfig, readRawConfigAtRoot, readRawScopeConfig, updateRawConfigAtomically, persistDefaultKindModel, persistModelLadders, readMergedLaunchConfig } from '../../core/config.js';
14
14
  import { findProjectScopeRoots, userScopeRoot } from '../../core/scope.js';
15
15
  import { loadProfileManifest, profileRoot } from '../../core/profiles/manifest.js';
16
+ import { CONDENSED_HISTORY_MODES } from '../../types.js';
16
17
  import { BINDING_CATALOG, formatBinding, formatGesture, matchesPiTuiInput, persistUserKeybindings, resolveKeybindings, KeybindingConcurrentEditError, KeybindingValidationError, } from '../../core/keybindings/index.js';
17
18
  import { configuredLadderProviders, inferPortableModelSelection } from '../../core/runtime/model-selection.js';
18
19
  import { cliClient } from '../api-client.js';
@@ -79,7 +80,11 @@ function currentWidth() {
79
80
  return Math.max(1, process.stdout.columns ?? 80);
80
81
  }
81
82
  function toolLabel(tool) {
82
- return tool === 'nvim' ? 'nvim' : 'tmux';
83
+ if (tool === 'nvim')
84
+ return 'nvim';
85
+ if (tool === 'nerd-font')
86
+ return 'Nerd Font';
87
+ return 'tmux';
83
88
  }
84
89
  function positiveInteger(value) {
85
90
  if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
@@ -652,6 +657,45 @@ class ProviderPanel {
652
657
  return useFrame ? framed(body, width) : body.map((line) => truncateToWidth(line, width));
653
658
  }
654
659
  }
660
+ const TAB_LABELS = ['Providers', 'Plugins', 'System', 'Exa', 'Keybindings', 'Models', 'Broker limits', 'Display'];
661
+ const DISPLAY_TAB = 7;
662
+ /** The viewer display settings the Display tab edits, in row order. Each row owns
663
+ * its own value cycling and its own formatting, so adding a setting here is the
664
+ * whole change — the tab's input and render loops stay generic. Every one is a
665
+ * user-scope `ScopeConfig` key persisted the moment it changes. */
666
+ const DISPLAY_ROWS = [
667
+ {
668
+ key: 'fold_finished_tools',
669
+ label: 'Fold finished tools',
670
+ description: 'Fold every settled tool call to its call line (path included). Toggle live with /fold-tools.',
671
+ format: (cfg) => (cfg.fold_finished_tools ? 'on' : 'off'),
672
+ next: (cfg) => ({ fold_finished_tools: !cfg.fold_finished_tools }),
673
+ },
674
+ {
675
+ key: 'completion_bell',
676
+ label: 'Completion bell',
677
+ description: 'Ring a short sound in the viewer when the node you are watching finishes a turn.',
678
+ format: (cfg) => (cfg.completion_bell ? 'on' : 'off'),
679
+ next: (cfg) => ({ completion_bell: !cfg.completion_bell }),
680
+ },
681
+ {
682
+ key: 'live_cycles',
683
+ label: 'Live cycles',
684
+ description: 'Trailing refresh cycles rendered at full fidelity; older cycles condense to text. Default 2.',
685
+ format: (cfg) => String(cfg.live_cycles),
686
+ next: (cfg, delta) => ({ live_cycles: ((cfg.live_cycles - 1 + delta + 6) % 6) + 1 }),
687
+ },
688
+ {
689
+ key: 'condensed_history',
690
+ label: 'Condensed history',
691
+ description: 'Which messages of a condensed cycle still render. Tool calls are never kept.',
692
+ format: (cfg) => cfg.condensed_history,
693
+ next: (cfg, delta) => ({
694
+ condensed_history: CONDENSED_HISTORY_MODES[(CONDENSED_HISTORY_MODES.indexOf(cfg.condensed_history) + delta + CONDENSED_HISTORY_MODES.length)
695
+ % CONDENSED_HISTORY_MODES.length],
696
+ }),
697
+ },
698
+ ];
655
699
  const STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
656
700
  const LADDER_PROVIDER_KEYS = ['anthropic', 'openai'];
657
701
  const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
@@ -1265,6 +1309,8 @@ export class SetupWizard {
1265
1309
  keyNotice;
1266
1310
  brokerLimitIndex = 0;
1267
1311
  brokerLimitNotice;
1312
+ displayIndex = 0;
1313
+ displayNotice;
1268
1314
  cachedWidth;
1269
1315
  cachedHeight;
1270
1316
  cachedLines;
@@ -1336,9 +1382,39 @@ export class SetupWizard {
1336
1382
  this.invalidate();
1337
1383
  }
1338
1384
  moveTab(delta) {
1339
- const next = ((this.activeTab + delta + 7) % 7);
1385
+ const next = ((this.activeTab + delta + TAB_LABELS.length) % TAB_LABELS.length);
1340
1386
  this.setTab(next);
1341
1387
  }
1388
+ moveDisplayRow(delta) {
1389
+ this.displayIndex = (this.displayIndex + delta + DISPLAY_ROWS.length) % DISPLAY_ROWS.length;
1390
+ this.displayNotice = undefined;
1391
+ this.invalidate();
1392
+ }
1393
+ /** Advance the selected display setting to its next value and persist it to user
1394
+ * config immediately — these are one-keystroke view preferences, so there is no
1395
+ * separate save gesture to forget. A viewer picks the new value up on its next
1396
+ * attach (`live_cycles` / `condensed_history` / `fold_finished_tools` are all read
1397
+ * per ChatView), and `completion_bell` on the next turn end. */
1398
+ cycleDisplaySetting(delta) {
1399
+ const row = DISPLAY_ROWS[this.displayIndex];
1400
+ const current = readConfig('user');
1401
+ const next = row.next(current, delta);
1402
+ updateRawConfigAtomically('user', (latest) => ({ ...latest, ...next }));
1403
+ this.displayNotice = `Saved ${row.key} = ${row.format({ ...current, ...next })}. Applies on the next attach.`;
1404
+ this.invalidate();
1405
+ }
1406
+ handleDisplayInput(data) {
1407
+ if (this.matches('crtr.setup.list-down', data))
1408
+ this.moveDisplayRow(1);
1409
+ else if (this.matches('crtr.setup.list-up', data))
1410
+ this.moveDisplayRow(-1);
1411
+ else if (this.matches('crtr.setup.toggle', data) || this.matches('crtr.setup.cell-next', data))
1412
+ this.cycleDisplaySetting(1);
1413
+ else if (this.matches('crtr.setup.cell-previous', data))
1414
+ this.cycleDisplaySetting(-1);
1415
+ else if (this.matches('crtr.setup.advance', data))
1416
+ this.moveTab(1);
1417
+ }
1342
1418
  cancel() {
1343
1419
  if (this.confirming) {
1344
1420
  this.confirming = false;
@@ -1899,11 +1975,15 @@ export class SetupWizard {
1899
1975
  this.invalidate();
1900
1976
  return;
1901
1977
  }
1978
+ if (this.activeTab === DISPLAY_TAB) {
1979
+ this.handleDisplayInput(data);
1980
+ return;
1981
+ }
1902
1982
  this.handleBrokerLimitsInput(data);
1903
1983
  }
1904
1984
  renderHeader(width) {
1905
- const tabs = ['Providers', 'Plugins', 'System', 'Exa', 'Keybindings', 'Models', 'Broker limits'];
1906
- const fullTabLine = tabs
1985
+ const tabs = TAB_LABELS;
1986
+ const fullTabLine = tabs.slice()
1907
1987
  .map((label, index) => tabPill(label, index === this.activeTab))
1908
1988
  .join(theme.fg('dim', ' · '));
1909
1989
  const tabLine = visibleWidth(fullTabLine) <= width
@@ -1923,6 +2003,9 @@ export class SetupWizard {
1923
2003
  : `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} row · ${this.label('crtr.setup.cell-previous')}/${this.label('crtr.setup.cell-next')} column · ${this.label('crtr.setup.models.scope-next')} scope · ${this.label('crtr.setup.models.open')} edit · ${this.label('crtr.setup.tab-next')} next · ${this.label('crtr.setup.cancel')} cancel`;
1924
2004
  return [truncateToWidth(theme.fg('dim', hint), width)];
1925
2005
  }
2006
+ if (this.activeTab === DISPLAY_TAB) {
2007
+ return [truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} row · ${this.label('crtr.setup.toggle')} or ${this.label('crtr.setup.cell-next')}/${this.label('crtr.setup.cell-previous')} change value · ${tabs} · ${this.label('crtr.setup.cancel')} cancel`), width)];
2008
+ }
1926
2009
  if (this.activeTab === 6) {
1927
2010
  return [truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} field · type value · Enter save · ${this.fieldSafeLabel('crtr.setup.tab-next')}/${this.fieldSafeLabel('crtr.setup.tab-previous')} switch tabs · ${this.label('crtr.setup.cancel')} cancel`), width)];
1928
2011
  }
@@ -1973,7 +2056,7 @@ export class SetupWizard {
1973
2056
  }
1974
2057
  renderSystems(width) {
1975
2058
  const out = [];
1976
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('System deps'))}${theme.fg('dim', ' tmux + nvim check')}`, width));
2059
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('System deps'))}${theme.fg('dim', ' tmux + nvim + Nerd Font check')}`, width));
1977
2060
  for (const [index, tool] of this.systemTools.entries()) {
1978
2061
  const selected = index === this.systemIndex;
1979
2062
  const marker = selected ? theme.fg('accent', '▶') : theme.fg('dim', ' ');
@@ -2172,6 +2255,26 @@ export class SetupWizard {
2172
2255
  }
2173
2256
  return out;
2174
2257
  }
2258
+ renderDisplay(width) {
2259
+ const out = [];
2260
+ const cfg = readConfig('user');
2261
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Display'))}${theme.fg('dim', ' user scope · attach viewer')}`, width));
2262
+ out.push(truncateToWidth(theme.fg('dim', 'Each change saves immediately and applies to viewers attached from now on.'), width));
2263
+ out.push('');
2264
+ for (const [index, row] of DISPLAY_ROWS.entries()) {
2265
+ const selected = index === this.displayIndex;
2266
+ const marker = selected ? theme.fg('accent', '▶') : ' ';
2267
+ const name = selected ? theme.fg('accent', row.label) : row.label;
2268
+ out.push(truncateToWidth(`${marker} ${padAnsi(name, 24)} ${theme.fg('success', row.format(cfg))}`, width));
2269
+ out.push(truncateToWidth(theme.fg('dim', ` ${row.description}`), width));
2270
+ }
2271
+ if (this.displayNotice) {
2272
+ out.push('');
2273
+ for (const line of wrapText(this.displayNotice, width))
2274
+ out.push(theme.fg('success', line));
2275
+ }
2276
+ return out;
2277
+ }
2175
2278
  render(width) {
2176
2279
  const bypass = this.activeTab === 5 && this.models.isPickerOpen();
2177
2280
  const height = this.terminalRows();
@@ -2194,7 +2297,9 @@ export class SetupWizard {
2194
2297
  ? this.renderKeybindings(width)
2195
2298
  : this.activeTab === 5
2196
2299
  ? this.renderModels(width)
2197
- : this.renderBrokerLimits(width)),
2300
+ : this.activeTab === 6
2301
+ ? this.renderBrokerLimits(width)
2302
+ : this.renderDisplay(width)),
2198
2303
  '',
2199
2304
  ...this.renderFooter(width),
2200
2305
  ];
@@ -75,7 +75,7 @@ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
75
75
  await wait(stepMs);
76
76
  }
77
77
  }
78
- test('coalesce inlines short reports with their absolute path and only hints for remaining canonical refs', () => {
78
+ test('coalesce inlines short report bodies with a trailing artifact pointer and only hints for remaining canonical refs', () => {
79
79
  freshNode('child');
80
80
  const child = createNode({
81
81
  node_id: 'child',
@@ -93,8 +93,10 @@ test('coalesce inlines short reports with their absolute path and only hints for
93
93
  const digest = coalesce([
94
94
  finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'update', label: 'short report', ref: 'child:reports/short-update.md' }),
95
95
  ]);
96
- assert.match(digest, new RegExp(`\\[update\\] short report at ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`));
97
- assert.match(digest, /short report body/);
96
+ // The label is NOT reprinted above the body it duplicates; the path trails it.
97
+ assert.doesNotMatch(digest, /\[update\] short report/);
98
+ assert.match(digest, /\[update\]\n {4}short report body\n/);
99
+ assert.match(digest, new RegExp(`\\(report: ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
98
100
  assert.doesNotMatch(digest, /Dereference a ref with/);
99
101
  const guidance = 'Dereference a ref with `crtr canvas history read <ref>`.';
100
102
  const canonical = coalesce([