@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
@@ -205,7 +205,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
205
205
  const currentNode = defaultMenu.indexOf('-#[bold]Current node');
206
206
  const lifecycle = defaultMenu.indexOf('-#[bold]Lifecycle');
207
207
  const system = defaultMenu.indexOf('-#[bold]System');
208
- for (const item of ['resume node', 'graph view', 'focus manager', 'show ›']) {
208
+ for (const item of ['resume node', 'graph view', 'focus manager', 'show ›', 'view settings ›']) {
209
209
  assert.ok(defaultMenu.indexOf(item) > explore && defaultMenu.indexOf(item) < currentNode, `${item} belongs in Explore`);
210
210
  }
211
211
  assert.match(defaultMenu, /"show ›" s .*display-menu.*metadata.*context \+ reports/);
@@ -213,7 +213,10 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
213
213
  assert.ok(defaultMenu.indexOf(item) > currentNode && defaultMenu.indexOf(item) < lifecycle, `${item} belongs to the current node`);
214
214
  }
215
215
  assert.ok(defaultMenu.indexOf('fork into idle root') > lifecycle && defaultMenu.indexOf('fork into idle root') < system, 'fork belongs in Lifecycle');
216
- assert.ok(defaultMenu.indexOf('settings') > system, 'settings belongs in System');
216
+ assert.match(defaultMenu, /"view settings ›" w .*display-menu.*fold finished tool calls/);
217
+ // Matched with its selector, so the Explore group's "view settings ›" item
218
+ // (selector `w`) cannot satisfy this assertion by substring.
219
+ assert.ok(defaultMenu.indexOf('settings p ') > system, 'settings belongs in System');
217
220
  assert.ok(defaultMenu.indexOf('create ›') > system, 'create belongs in System');
218
221
  assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
219
222
  // The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
@@ -5,9 +5,20 @@ export interface BashJobPaths {
5
5
  jobLog: string;
6
6
  jobExit: string;
7
7
  jobBg: string;
8
+ /** Process-group id of the job's detached supervisor, written at spawn. It is
9
+ * what makes a job stoppable from outside the agent that started it (the
10
+ * Inspector's cancel) — without it on disk, only the agent's own handoff
11
+ * notice knows the group to signal. */
12
+ jobPgid: string;
8
13
  }
9
14
  export declare function bashJobsDir(contextDir: string): string;
10
15
  export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
16
+ /** The job's supervisor process group, or undefined for a job started before
17
+ * pgid was persisted (or a partially-written dir). */
18
+ export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
19
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
20
+ * rather than the whole file, which for a long-running job can be enormous. */
21
+ export declare function tailJobLog(logPath: string, count: number): string[];
11
22
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
12
23
  * valve calls this instead of building the literal inline so the epoch-ms
13
24
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -3,7 +3,7 @@
3
3
  // the prefix menu requests backgrounding by creating job.bg. Keeping that signal
4
4
  // on disk means the command can outlive its pi process and still report its exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  export function bashJobsDir(contextDir) {
9
9
  return join(contextDir, 'jobs');
@@ -17,8 +17,48 @@ export function bashJobPaths(contextDir, jobId) {
17
17
  jobLog: join(dir, 'job.log'),
18
18
  jobExit: join(dir, 'job.exit'),
19
19
  jobBg: join(dir, 'job.bg'),
20
+ jobPgid: join(dir, 'job.pgid'),
20
21
  };
21
22
  }
23
+ /** The job's supervisor process group, or undefined for a job started before
24
+ * pgid was persisted (or a partially-written dir). */
25
+ export function readJobPgid(contextDir, jobId) {
26
+ try {
27
+ const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobPgid, 'utf8').trim());
28
+ return Number.isInteger(n) && n > 1 ? n : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
35
+ * rather than the whole file, which for a long-running job can be enormous. */
36
+ export function tailJobLog(logPath, count) {
37
+ if (count <= 0)
38
+ return [];
39
+ const WINDOW = 64 * 1024;
40
+ let fd;
41
+ try {
42
+ const size = statSync(logPath).size;
43
+ const start = Math.max(0, size - WINDOW);
44
+ const buf = Buffer.alloc(Math.min(size, WINDOW));
45
+ fd = openSync(logPath, 'r');
46
+ readSync(fd, buf, 0, buf.length, start);
47
+ const text = buf.toString('utf8');
48
+ // A partial first line (we cut mid-line) is dropped, not shown truncated.
49
+ const lines = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
50
+ if (lines[lines.length - 1] === '')
51
+ lines.pop();
52
+ return lines.slice(-count);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ finally {
58
+ if (fd !== undefined)
59
+ closeSync(fd);
60
+ }
61
+ }
22
62
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
23
63
  * valve calls this instead of building the literal inline so the epoch-ms
24
64
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -21,7 +21,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
21
21
  /** The identity keys meta.json persists. Listed explicitly so no runtime field
22
22
  * can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
23
23
  const IDENTITY_KEYS = [
24
- 'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
24
+ 'node_id', 'name', 'description', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
25
25
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'human_work_target', 'private_fork_phase',
26
26
  'private_fork_recipe', 'passive_default', 'pi_session_id', 'pi_session_file', 'cycle_pending', 'model_override',
27
27
  'launch', 'managed_worktree',
@@ -9,6 +9,7 @@ export interface NamedNode {
9
9
  name: string;
10
10
  kind: string;
11
11
  description?: string;
12
+ icon?: string;
12
13
  }
13
14
  /** A node's human label: its explicit handle plus the pi-generated description
14
15
  * of its first task, e.g. `fix-auth refactor-login-flow`. The handle is
@@ -34,5 +34,7 @@ export function personaLabel(meta) {
34
34
  * badge. Recomputed from meta on every revive and pushed live when naming
35
35
  * generates the first task description. */
36
36
  export function editorLabel(meta) {
37
- return fullName(meta);
37
+ const icon = (meta.icon ?? '').trim();
38
+ const name = fullName(meta);
39
+ return icon !== '' ? `${icon} ${name}` : name;
38
40
  }
@@ -23,6 +23,12 @@ export declare const GRAY = "\u001B[90m";
23
23
  * `isPidAlive` / `isBusy` reads entirely (a remote node id may collide with a
24
24
  * local one) and renders the bare status glyph instead. */
25
25
  export declare function nodeGlyph(node: NodeMeta | null, remote?: boolean): string;
26
+ /** Drop every SGR sequence, keeping the visible text. The selection bar uses
27
+ * this: reverse-video INVERTS a coloured cell into a coloured block, so a row
28
+ * built from tinted spans highlights as a patchwork of background swatches
29
+ * instead of one bar. Stripping colour first leaves a single clean bar — the
30
+ * glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
31
+ export declare function stripSgr(s: string): string;
26
32
  /** Visible width, ignoring ANSI escapes. */
27
33
  export declare function visibleWidth(s: string): number;
28
34
  /** Truncate to `max` VISIBLE columns: escape sequences are copied through
@@ -71,6 +71,14 @@ export function nodeGlyph(node, remote = false) {
71
71
  return `${ESC}${v.color}m${v.glyph}${RESET}`;
72
72
  }
73
73
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
74
+ /** Drop every SGR sequence, keeping the visible text. The selection bar uses
75
+ * this: reverse-video INVERTS a coloured cell into a coloured block, so a row
76
+ * built from tinted spans highlights as a patchwork of background swatches
77
+ * instead of one bar. Stripping colour first leaves a single clean bar — the
78
+ * glyphs (●/○/✓/✗/⚙/◷) still carry the signal, exactly as under NO_COLOR. */
79
+ export function stripSgr(s) {
80
+ return s.replace(ANSI_RE, '');
81
+ }
74
82
  /** Visible width, ignoring ANSI escapes. */
75
83
  export function visibleWidth(s) {
76
84
  return s.replace(ANSI_RE, '').length;
@@ -87,6 +87,12 @@ export interface NodeIdentity {
87
87
  /** A 2-4 word kebab-case handle derived from the node's first prompt (named
88
88
  * headlessly by pi; see runtime/naming.ts). Shown in the editor label. */
89
89
  description?: string;
90
+ /** A single Nerd Font glyph depicting the node's work, chosen by the same
91
+ * headless namer that produced `description` (see runtime/naming.ts). Kept as
92
+ * its own field rather than baked into the description so each surface decides
93
+ * whether to render it; absent on a node named before icons existed, or when
94
+ * the model returned nothing usable. */
95
+ icon?: string;
90
96
  /** How many times this node has been (re)launched — born at 0, bumped on
91
97
  * every revive. Exposed to the pi footer as CRTR_CYCLES so a refresh/crash
92
98
  * cycle reads at a glance there. */
@@ -359,12 +359,13 @@ function mergeConfig(partial) {
359
359
  const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
360
360
  ? partial.condensed_history
361
361
  : defaults.condensed_history;
362
+ const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
362
363
  const keybindings = normalizeKeybindings(partial.keybindings);
363
364
  const modelLadders = mergeModelLadders(partial.modelLadders);
364
365
  const kinds = mergeKinds(partial.kinds);
365
366
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
366
367
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
367
- return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
368
+ return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
368
369
  }
369
370
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
370
371
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -497,7 +498,7 @@ export function subKindsAvailableTo(kind) {
497
498
  * an untouched key is never rewritten. */
498
499
  const CONFIG_TOP_LEVEL_KEYS = [
499
500
  'schema_version', 'marketplaces', 'plugins', 'configuredClis', 'auto_update',
500
- 'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'keybindings', 'modelLadders', 'kinds',
501
+ 'max_panes_per_window', 'brokerThresholds', 'completion_bell', 'live_cycles', 'condensed_history', 'fold_finished_tools', 'keybindings', 'modelLadders', 'kinds',
501
502
  'remoteCanvas', 'spawnEnv',
502
503
  ];
503
504
  /** The SPARSE `modelLadders` to persist after a config mutation: the raw
@@ -75,9 +75,9 @@ export declare function clipBody(body: string): {
75
75
  *
76
76
  * Format (per sender group):
77
77
  * From <sender> — <N> update(s):
78
- * [<kind>] <label> at <absolute report path>:
79
- *
80
- * <short report body>
78
+ * [<kind>]
79
+ * <short report body>
80
+ * (report: <absolute report path>)
81
81
  *
82
82
  * Large reports remain canonical pointers and add one shared dereference hint.
83
83
  */
@@ -330,15 +330,19 @@ function inlineReport(e) {
330
330
  /**
331
331
  * Render one entry's digest line(s).
332
332
  *
333
- * Short pushed reports are inlined with their absolute report path. Larger
334
- * reports stay canonical history pointers. Direct messages retain their bounded
333
+ * A short pushed report is inlined as its own body, followed by a pointer to
334
+ * the durable artifact. The label is NOT repeated above it: a report's label is
335
+ * just its first line (feed.ts `firstLine`), so printing both reads as a
336
+ * truncated preview stacked on the full text it previews. Larger reports stay
337
+ * canonical history pointers. Direct messages retain their bounded
335
338
  * inline-body/spill behavior.
336
339
  */
337
340
  function renderEntry(e) {
338
341
  const report = inlineReport(e);
339
342
  if (report !== null) {
343
+ const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
340
344
  return {
341
- text: ` [${e.kind}] ${e.label} at ${report.path}:\n\n${report.body}`,
345
+ text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
342
346
  hasCanonicalRef: false,
343
347
  };
344
348
  }
@@ -364,9 +368,9 @@ function renderEntry(e) {
364
368
  *
365
369
  * Format (per sender group):
366
370
  * From <sender> — <N> update(s):
367
- * [<kind>] <label> at <absolute report path>:
368
- *
369
- * <short report body>
371
+ * [<kind>]
372
+ * <short report body>
373
+ * (report: <absolute report path>)
370
374
  *
371
375
  * Large reports remain canonical pointers and add one shared dereference hint.
372
376
  */
@@ -1,6 +1,14 @@
1
1
  import type { CronDTO } from '../../api/dto/crons.js';
2
2
  import type { SourceError, ViewCore } from '../view/contract.js';
3
- import { scheduledItems, type InspectorSection } from './model.js';
3
+ import { scheduledItems, type BashJobRow, type InspectorSection } from './model.js';
4
+ /** What a `y` confirmation will act on. Both sections arm cancellation the same
5
+ * way, so the pending action carries its own kind rather than the view guessing
6
+ * from whichever section happens to be open when the key lands. */
7
+ export type PendingAction = {
8
+ kind: 'cron' | 'job';
9
+ id: string;
10
+ label: string;
11
+ };
4
12
  export interface InspectorState {
5
13
  target: string;
6
14
  section: InspectorSection;
@@ -14,10 +22,17 @@ export interface InspectorState {
14
22
  error: SourceError | null;
15
23
  fetchedAt: number;
16
24
  };
25
+ jobs: {
26
+ data: BashJobRow[] | null;
27
+ error: SourceError | null;
28
+ fetchedAt: number;
29
+ };
17
30
  selectedCronId: string | null;
31
+ selectedJobId: string | null;
18
32
  expanded: boolean;
19
- pendingCancel: string | null;
33
+ pendingCancel: PendingAction | null;
20
34
  scroll: Record<InspectorSection, number>;
21
35
  }
22
36
  export declare const inspectorCore: ViewCore<InspectorState>;
37
+ export declare function selectedJob(state: InspectorState): BashJobRow | undefined;
23
38
  export { scheduledItems };
@@ -35,6 +35,25 @@ const scheduleSource = {
35
35
  return { ok: true, data: crons };
36
36
  },
37
37
  };
38
+ /** Trailing log lines carried with every job row: enough to fill the expanded
39
+ * card, so watching a job needs no second surface. */
40
+ const LOG_TAIL_LINES = 14;
41
+ /** Live background bash jobs for the inspected node. Non-blocking, exactly like
42
+ * the schedule: a node with no jobs dir (or a remote target) degrades this one
43
+ * section, never the whole view. */
44
+ const jobsSource = {
45
+ id: 'jobs',
46
+ request: (target) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'list', '--node', target, '--tail', String(LOG_TAIL_LINES)] }),
47
+ parse: (raw) => {
48
+ const result = parseJson(raw, 'node bash list', false);
49
+ if (!result.ok)
50
+ return result;
51
+ const jobs = result.data['jobs'];
52
+ if (!Array.isArray(jobs))
53
+ return { ok: false, error: error('parse', 'Jobs unavailable', 'The job list did not contain job rows.', false) };
54
+ return { ok: true, data: jobs };
55
+ },
56
+ };
38
57
  const cancelCommand = {
39
58
  id: 'cancel',
40
59
  request: (id) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'cron', 'cancel', id] }),
@@ -45,8 +64,50 @@ const cancelCommand = {
45
64
  return { ok: true, data: { canceled: String(result.data['canceled'] ?? '') } };
46
65
  },
47
66
  };
67
+ const killJobCommand = {
68
+ id: 'kill-job',
69
+ request: ({ target, job }) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'kill', job, '--node', target] }),
70
+ parse: (raw) => {
71
+ const result = parseJson(raw, 'node bash kill', false);
72
+ if (!result.ok)
73
+ return result;
74
+ return { ok: true, data: { job: String(result.data['job_id'] ?? '') } };
75
+ },
76
+ };
77
+ /** Open the selected job's log in its own tmux window, following like `tail -f`.
78
+ * A new window rather than a nested popup: tmux allows one popup per client and
79
+ * the Inspector is usually already in one — and a window is where you actually
80
+ * want a log you intend to keep watching after the Inspector closes. */
81
+ const openLogCommand = {
82
+ id: 'open-log',
83
+ request: ({ path, title }) => ({
84
+ kind: 'exec',
85
+ bin: 'tmux',
86
+ args: ['new-window', '-n', title, `less +F -- ${quote(path)} 2>/dev/null || tail -f -- ${quote(path)}`],
87
+ }),
88
+ parse: (raw) => {
89
+ if (!raw.ok || (raw.exitCode ?? 0) !== 0) {
90
+ return { ok: false, error: error('no_tmux', 'Could not open the log', raw.stderr || 'tmux new-window failed — the Inspector must be running inside tmux.', false) };
91
+ }
92
+ return { ok: true, data: {} };
93
+ },
94
+ };
95
+ function quote(text) {
96
+ return `'${text.split("'").join(`'\\''`)}'`;
97
+ }
98
+ const SECTION_ORDER = ['overview', 'schedule', 'jobs', 'raw'];
48
99
  function section(value) {
49
- return value === 'schedule' || value === 'raw' ? value : 'overview';
100
+ return value === 'schedule' || value === 'jobs' || value === 'raw' ? value : 'overview';
101
+ }
102
+ /** The section-aware status line: what is pending, counted for what you see. */
103
+ function countsFor(state) {
104
+ const crons = `${scheduledItems(state.schedule.data ?? []).length} cron(s) armed`;
105
+ const jobs = `${(state.jobs.data ?? []).length} background job(s) running`;
106
+ if (state.section === 'schedule')
107
+ return crons;
108
+ if (state.section === 'jobs')
109
+ return jobs;
110
+ return `${crons} · ${jobs}`;
50
111
  }
51
112
  function reconcile(state, next) {
52
113
  const items = scheduledItems(next);
@@ -58,21 +119,30 @@ function reconcile(state, next) {
58
119
  return { ...state, schedule: { data: next, error: null, fetchedAt: Date.now() }, selectedCronId: replacement?.cron.cron_id ?? null, expanded: false, pendingCancel: null };
59
120
  }
60
121
  export const inspectorCore = {
61
- manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview and scheduled crons', refreshMs: 5000 },
122
+ manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview, scheduled crons, and live background bash jobs', refreshMs: 5000 },
62
123
  init(options) {
63
124
  return {
64
125
  target: options['target'] ?? '', section: section(options['section']),
65
126
  dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 },
66
- selectedCronId: null, expanded: false, pendingCancel: null,
67
- scroll: { overview: 0, schedule: 0, raw: 0 },
127
+ jobs: { data: null, error: null, fetchedAt: 0 },
128
+ selectedCronId: null, selectedJobId: null, expanded: false, pendingCancel: null,
129
+ scroll: { overview: 0, schedule: 0, jobs: 0, raw: 0 },
68
130
  };
69
131
  },
70
- sources: { dossier: dossierSource, schedule: scheduleSource },
71
- commands: { cancel: cancelCommand },
132
+ sources: { dossier: dossierSource, schedule: scheduleSource, jobs: jobsSource },
133
+ commands: { cancel: cancelCommand, killJob: killJobCommand, openLog: openLogCommand },
72
134
  intents: {
73
135
  async refresh(ctx) {
74
136
  ctx.signal.setStatus('Refreshing…');
75
- const dossier = await ctx.resolve(dossierSource, ctx.state.target);
137
+ let note;
138
+ // All three reads are independent subprocesses, so they run TOGETHER: the
139
+ // host drops keystrokes while an intent is in flight, and the auto-refresh
140
+ // fires every 5s — resolving them in series would make the view feel deaf.
141
+ const [dossier, schedule, jobs] = await Promise.all([
142
+ ctx.resolve(dossierSource, ctx.state.target),
143
+ ctx.resolve(scheduleSource, ctx.state.target),
144
+ ctx.resolve(jobsSource, ctx.state.target),
145
+ ]);
76
146
  if (!dossier.ok) {
77
147
  ctx.set((state) => ({ ...state, dossier: { ...state.dossier, error: dossier.error } }));
78
148
  ctx.signal.setBanner(`${dossier.error.display.headline}: ${dossier.error.display.explanation}`, 'error');
@@ -80,7 +150,6 @@ export const inspectorCore = {
80
150
  return;
81
151
  }
82
152
  ctx.set((state) => ({ ...state, dossier: { data: dossier.data, error: null, fetchedAt: Date.now() } }));
83
- const schedule = await ctx.resolve(scheduleSource, ctx.state.target);
84
153
  if (!schedule.ok) {
85
154
  ctx.set((state) => ({ ...state, schedule: { ...state.schedule, error: schedule.error } }));
86
155
  ctx.signal.setBanner(schedule.error.display.explanation, 'error');
@@ -89,37 +158,90 @@ export const inspectorCore = {
89
158
  const before = ctx.state.selectedCronId;
90
159
  ctx.set((state) => reconcile(state, schedule.data));
91
160
  if (before !== null && !schedule.data.some((cron) => cron.cron_id === before))
92
- ctx.signal.setStatus('Selected cron disappeared; moved to the next item.');
93
- else
94
- ctx.signal.setStatus(`${schedule.data.length} cron(s) armed`);
95
- ctx.signal.clearBanner();
161
+ note = 'Selected cron disappeared; moved to the next item.';
162
+ // Clear ONLY a banner this source raised. A background refresh must not
163
+ // wipe the user's own "press y to confirm" prompt or an action result.
164
+ if (ctx.state.schedule.error !== null)
165
+ ctx.signal.clearBanner();
166
+ }
167
+ if (!jobs.ok)
168
+ ctx.set((state) => ({ ...state, jobs: { ...state.jobs, error: jobs.error } }));
169
+ else {
170
+ const gone = ctx.state.selectedJobId !== null && !jobs.data.some((job) => job.job_id === ctx.state.selectedJobId);
171
+ ctx.set((state) => reconcileJobs(state, jobs.data));
172
+ if (gone)
173
+ note = 'The selected job finished; moved to the next one.';
96
174
  }
175
+ // One status line, always about the section you are looking at: a count of
176
+ // crons in schedule, of jobs in jobs, of both in the whole-node sections.
177
+ ctx.signal.setStatus(note ?? countsFor(ctx.state));
97
178
  const node = record(ctx.state.dossier.data?.['node']);
98
179
  ctx.signal.setSubtitle(`${node['name'] ?? ctx.state.target} · ${ctx.state.section}`);
99
180
  },
100
- sectionNext(ctx) { const order = ['overview', 'schedule', 'raw']; const next = order[(order.indexOf(ctx.state.section) + 1) % order.length]; ctx.set((state) => ({ ...state, section: next })); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
101
- sectionPrev(ctx) { const order = ['overview', 'schedule', 'raw']; const next = order[(order.indexOf(ctx.state.section) + order.length - 1) % order.length]; ctx.set((state) => ({ ...state, section: next })); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
181
+ sectionNext(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
182
+ sectionPrev(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + order.length - 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
102
183
  down(ctx) { ctx.set((state) => move(state, 1)); },
103
184
  up(ctx) { ctx.set((state) => move(state, -1)); },
104
185
  pageDown(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: state.scroll[state.section] + 8 } })); },
105
186
  pageUp(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] - 8) } })); },
106
- toggleDetail(ctx) { if (ctx.state.selectedCronId !== null)
107
- ctx.set((state) => ({ ...state, expanded: !state.expanded })); },
108
- cancelItem(ctx) { if (ctx.state.selectedCronId === null)
109
- return; ctx.set((state) => ({ ...state, pendingCancel: state.selectedCronId })); ctx.signal.setBanner(`Cancel ${ctx.state.selectedCronId}? Press y to confirm.`, 'action'); },
187
+ toggleDetail(ctx) {
188
+ if (ctx.state.section === 'jobs' ? ctx.state.selectedJobId !== null : ctx.state.selectedCronId !== null) {
189
+ ctx.set((state) => ({ ...state, expanded: !state.expanded }));
190
+ }
191
+ },
192
+ cancelItem(ctx) {
193
+ const pending = ctx.state.section === 'jobs'
194
+ ? selectedJob(ctx.state) && { kind: 'job', id: selectedJob(ctx.state).job_id, label: commandHead(selectedJob(ctx.state).command) }
195
+ : ctx.state.selectedCronId !== null && { kind: 'cron', id: ctx.state.selectedCronId, label: ctx.state.selectedCronId };
196
+ if (!pending)
197
+ return;
198
+ if (pending.kind === 'job' && selectedJob(ctx.state)?.pgid === null) {
199
+ ctx.signal.setBanner('This job predates recorded process groups — crtr cannot stop it. Open its log and stop it by hand.', 'error');
200
+ return;
201
+ }
202
+ ctx.set((state) => ({ ...state, pendingCancel: pending }));
203
+ ctx.signal.setBanner(pending.kind === 'job'
204
+ ? `Stop job “${pending.label}”? It dies now and the node is told. Press y to confirm.`
205
+ : `Cancel cron ${pending.label}? Press y to confirm.`, 'action');
206
+ },
207
+ async openLog(ctx) {
208
+ const job = selectedJob(ctx.state);
209
+ if (job === undefined)
210
+ return;
211
+ ctx.signal.setStatus('Opening the job log…');
212
+ const result = await ctx.execute(openLogCommand, { path: job.log_path, title: `job · ${commandHead(job.command, 18)}` });
213
+ if (!result.ok) {
214
+ ctx.signal.setBanner(result.error.display.explanation, 'error');
215
+ return;
216
+ }
217
+ ctx.signal.setBanner('Log opened in a new tmux window, following live. Quit the Inspector to read it.', 'info');
218
+ },
110
219
  async confirm(ctx) {
111
- const id = ctx.state.pendingCancel;
112
- if (id === null)
220
+ const pending = ctx.state.pendingCancel;
221
+ if (pending === null)
113
222
  return;
223
+ if (pending.kind === 'job') {
224
+ ctx.signal.setStatus('Stopping the job…');
225
+ const killed = await ctx.execute(killJobCommand, { target: ctx.state.target, job: pending.id });
226
+ ctx.set((state) => ({ ...state, pendingCancel: null }));
227
+ if (!killed.ok) {
228
+ ctx.signal.setBanner(killed.error.display.explanation, 'error');
229
+ return;
230
+ }
231
+ await ctx.dispatch('refresh');
232
+ const stillRunning = (ctx.state.jobs.data ?? []).some((job) => job.job_id === pending.id);
233
+ ctx.signal.setBanner(stillRunning ? 'The job is still running; refresh and retry.' : 'Job stopped — the node was told it was canceled.', stillRunning ? 'error' : 'info');
234
+ return;
235
+ }
114
236
  ctx.signal.setStatus('Canceling cron…');
115
- const result = await ctx.execute(cancelCommand, id);
237
+ const result = await ctx.execute(cancelCommand, pending.id);
116
238
  ctx.set((state) => ({ ...state, pendingCancel: null }));
117
239
  if (!result.ok) {
118
240
  ctx.signal.setBanner(result.error.display.explanation, 'error');
119
241
  return;
120
242
  }
121
243
  await ctx.dispatch('refresh');
122
- const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === id);
244
+ const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === pending.id);
123
245
  ctx.signal.setBanner(stillArmed ? 'Cron remains armed; refresh and retry.' : 'Cron canceled.', stillArmed ? 'error' : 'info');
124
246
  },
125
247
  dismiss(ctx) { ctx.set((state) => ({ ...state, pendingCancel: null })); ctx.signal.clearBanner(); },
@@ -128,14 +250,41 @@ export const inspectorCore = {
128
250
  const next = cron?.anchor_node ?? cron?.created_by;
129
251
  if (!next)
130
252
  return;
131
- ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 } }));
253
+ ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 }, jobs: { data: null, error: null, fetchedAt: 0 } }));
132
254
  void ctx.dispatch('refresh');
133
255
  },
134
256
  quit(ctx) { ctx.signal.quit(); },
135
257
  },
136
258
  };
137
259
  function selectedItem(state) { return scheduledItems(state.schedule.data ?? []).find((item) => item.cron.cron_id === state.selectedCronId); }
260
+ export function selectedJob(state) {
261
+ return (state.jobs.data ?? []).find((job) => job.job_id === state.selectedJobId);
262
+ }
263
+ /** First line of a command, for banners and window titles. */
264
+ function commandHead(command, cap = 40) {
265
+ const line = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
266
+ return line.length > cap ? `${line.slice(0, cap - 1)}…` : line;
267
+ }
268
+ /** Keep the job cursor on the same job across refreshes; when it finished, land
269
+ * on whatever took its place rather than losing the cursor entirely. */
270
+ function reconcileJobs(state, next) {
271
+ const fetched = { data: next, error: null, fetchedAt: Date.now() };
272
+ if (state.selectedJobId !== null && next.some((job) => job.job_id === state.selectedJobId))
273
+ return { ...state, jobs: fetched };
274
+ const previousIndex = (state.jobs.data ?? []).findIndex((job) => job.job_id === state.selectedJobId);
275
+ const replacement = next[Math.min(Math.max(0, previousIndex), Math.max(0, next.length - 1))];
276
+ const pendingCancel = state.pendingCancel?.kind === 'job' ? null : state.pendingCancel;
277
+ return { ...state, jobs: fetched, selectedJobId: replacement?.job_id ?? null, expanded: false, pendingCancel };
278
+ }
138
279
  function move(state, delta) {
280
+ if (state.section === 'jobs') {
281
+ const jobs = state.jobs.data ?? [];
282
+ if (jobs.length === 0)
283
+ return state;
284
+ const current = Math.max(0, jobs.findIndex((job) => job.job_id === state.selectedJobId));
285
+ const next = Math.max(0, Math.min(jobs.length - 1, current + delta));
286
+ return { ...state, selectedJobId: jobs[next].job_id, expanded: false, pendingCancel: null };
287
+ }
139
288
  if (state.section !== 'schedule')
140
289
  return { ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] + delta) } };
141
290
  const items = scheduledItems(state.schedule.data ?? []);
@@ -1,5 +1,17 @@
1
1
  import type { CronDTO } from '../../api/dto/crons.js';
2
- export type InspectorSection = 'overview' | 'schedule' | 'raw';
2
+ export type InspectorSection = 'overview' | 'schedule' | 'jobs' | 'raw';
3
+ /** One live background bash job, as `crtr --json node bash list` reports it. */
4
+ export interface BashJobRow {
5
+ job_id: string;
6
+ command: string;
7
+ started_at: string;
8
+ elapsed_ms: number;
9
+ log_path: string;
10
+ /** Supervisor process group — null for a job started before crtr persisted
11
+ * one, which is exactly the set of jobs the Inspector cannot stop. */
12
+ pgid: number | null;
13
+ log_tail: string[];
14
+ }
3
15
  export interface ScheduledItem {
4
16
  cron: CronDTO;
5
17
  nextAt: string;
@@ -14,6 +26,8 @@ export declare const ICON: {
14
26
  readonly artifacts: string;
15
27
  readonly paths: string;
16
28
  readonly cron: string;
29
+ readonly job: string;
30
+ readonly log: string;
17
31
  readonly section: string;
18
32
  readonly raw: string;
19
33
  readonly empty: string;
@@ -38,6 +52,21 @@ export declare function statusVisual(status: string): {
38
52
  tone: Tone;
39
53
  };
40
54
  export declare function scheduledItems(crons: readonly CronDTO[]): ScheduledItem[];
55
+ /** Elapsed duration in the shape every bash-handoff surface uses: `12s`,
56
+ * `3m 40s`, `1h 04m`. Duplicated from core/bash-jobs.ts on purpose — a view
57
+ * module stays a pure read and never pulls the node:fs job scanner in. */
58
+ export declare function elapsed(ms: number): string;
59
+ /** How a running job's age reads at a glance: an ordered ramp, not decoration.
60
+ * Fresh work is green, a job past 15m is amber, past an hour is red — the same
61
+ * good/warn/bad progression the node status glyphs use. */
62
+ export declare function jobTone(elapsedMs: number): Tone;
63
+ /** Milliseconds a job has been running, live from `started_at`. */
64
+ export declare function jobElapsedMs(job: BashJobRow, now?: number): number;
65
+ /** Live elapsed time for a job row — recomputed from `started_at` so the number
66
+ * keeps ticking between the view's 5s refreshes; `elapsed_ms` is the fallback. */
67
+ export declare function jobElapsed(job: BashJobRow, now?: number): string;
68
+ /** First line of a command, whitespace-collapsed and capped for a one-row cell. */
69
+ export declare function commandCell(command: string, cap?: number): string;
41
70
  export declare function relativeTime(iso: string | null, now?: number): string;
42
71
  /** How long ago an ISO timestamp was, e.g. `3d ago`. `—` when absent. */
43
72
  export declare function ago(iso: unknown, now?: number): string;