@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
@@ -86,6 +86,9 @@ export interface NodeWorktreeDTO {
86
86
  * `GET /v1/nodes/{id}` and by the create/lifecycle actions that yield a node. */
87
87
  export interface NodeDetailDTO extends NodeSummaryDTO {
88
88
  description?: string;
89
+ /** The Nerd Font glyph the namer chose for this node's work, when it has one
90
+ * (`NodeMeta.icon`). Rendered ahead of the label by surfaces that want it. */
91
+ icon?: string;
89
92
  cycles?: number;
90
93
  /** Approximate context-window token load of the node's live/last session,
91
94
  * used by the orchestrator yield-nudge (`childFollowUp`). Null when unknown
@@ -15,6 +15,7 @@ Open this dir whenever a task turns on understanding the runtime itself or chang
15
15
  - **nodes-and-canvas** — the agent-runtime model: nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle (mode + lifecycle axes), and revive (manual + daemon auto-revive).
16
16
  - **storage-tiers** — where every kind of state lives: the two tiers (scope root and canvas home) and their durability/ownership contracts.
17
17
  - **agent-shaping** — the when-to-use-which layer over the four dials that shape a node: kinds (the builtin roster, sub-kinds, and custom personas), modes (base vs orchestrator), profiles, and the memory tiers (node/profile/project/user/builtin).
18
+ - **workflow-codification** — the codification loop for repeatable tasks: do the work hands-on under a capture session, mine the HAR, then codify it as scripts (scope-root `scripts/`) + a slash-invokable memory doc, with the self-heal rule that a failed script is re-derived and rewritten.
18
19
  - **plugins** — authoring a crtr plugin: the plugin.json manifest, directory layout, scopes, install mechanics, versioning, command plugins (contributing top-level CLI commands via commands.json + one executable), and configured CLIs (contributing commands as definition + HTTP from a remote manifest, no executable).
19
20
  - **marketplaces** — authoring a crtr marketplace: the marketplace.json index, plugin entries, symlink-based install, auto-bump CI, dual-publishing.
20
21
  - **examples/** — worked compositions of the primitives into complete systems (the analogue of pi's `examples/` dir), e.g. the iMessage assistant node.
@@ -3,6 +3,8 @@ name: internal/storage-tiers
3
3
  kind: knowledge
4
4
  description: Where crouter state belongs
5
5
  when-and-why-to-read: When locating crtr state or deciding where a new file belongs, this reference should be read so files are found or placed in the storage tier with the right ownership and durability.
6
+ system-prompt-visibility: name
7
+ file-read-visibility: none
6
8
  short-form: The two crtr storage tiers — scope root for durable user/repo content and canvas home for node-graph runtime state, node artifacts, and human tickets.
7
9
  ---
8
10
 
@@ -0,0 +1,82 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When the user names a task as repeatable — "make this repeatable", "save this workflow", "we do this every month" — or when you are about to redo a multi-step task an agent has done before, this playbook should be read because codifying the task into scripts plus a slash-invokable doc makes every future run faster and more reliable than re-deriving it, and an uncodified repeat wastes the entire prior derivation.
4
+ short-form: "The codification loop: do a repeatable task hands-on under a capture session, mine the HAR for direct API calls, then codify it as a slash-invokable memory doc (judgment + facts) plus scripts (the fast path). Scripts live in scope-root scripts/; a failed script means re-derive hands-on and rewrite."
5
+ system-prompt-visibility: preview
6
+ file-read-visibility: none
7
+ ---
8
+
9
+ # Codifying repeatable work — workflows as a slash doc + scripts
10
+
11
+ A **codified workflow** is a convention assembled from two existing surfaces, each carrying what it is best at:
12
+
13
+ - **A workflow memory doc** with `slash: true` in its frontmatter, invocable as `/<name>`. It is the entry point and the single home for everything prose: the *judgment* (ordered steps, verification criteria, stop conditions — "if amounts mismatch, do NOT upload — flag it"), the *environment facts* that outlive any one run (which account bills when, which browser profile is logged in where, which API returns an app shell instead of a PDF), and pointers to the scripts.
14
+ - **Scripts** (`scripts/`, below) are the fast path. Each one deterministically performs a mechanical step the agent would otherwise re-derive: a direct API call mined from traffic, a DOM injection, a PDF fetch.
15
+
16
+ Judgment and facts stay in prose where the agent applies them fresh each run; mechanics compile into scripts that run in milliseconds. A workflow codified as *only* prose is permanently slow; codified as *only* scripts it breaks silently the first time the world drifts. Together they self-heal: **when a script fails, fall back to doing that step hands-on with `crtr capture`, then rewrite the script (and any stale facts in the doc) before finishing.** The workflow gets faster over time and never permanently breaks.
17
+
18
+ Codify only what the user has named repeatable, or what you are repeating yourself. A one-off task gets no scripts.
19
+
20
+ ## The loop
21
+
22
+ 1. **Run the task hands-on under a capture session.** `crtr capture session start --url <entry>` — the session auto-records HAR, so your clicks and navigations leave a complete traffic trace as a side effect of just doing the work. Sessions attach to the user's real logged-in browser (auto-detection prefers it; `--port` pins an endpoint; `--hold` with a later `--target` adopts an already-open tab).
23
+ 2. **Finish the task.** Correctness first — codification captures a *successful* run.
24
+ 3. **Mine the trace.** `crtr capture session har <id> --filter-url <domain> --full` inlines request headers, post data, and response bodies. You are looking for the direct path the UI was hiding: the JSON endpoint behind the dashboard, the presigned-URL hop behind the download button, the exact payload a form submit produced. What took ten clicks is usually one or two requests.
25
+ 4. **Codify.** Write the workflow doc (`slash: true`) and the scripts (shapes below). The doc references scripts by absolute path; scripts open with a header naming their owning doc.
26
+ 5. **Replay and repair.** Next time, `/<name>` runs the scripts in order. On any script failure: re-derive that step hands-on under a fresh session, rewrite the script in place, and update the doc if the *shape* of the task changed. Rewrite as if fresh — never append a changelog.
27
+
28
+ ## The scripts tier
29
+
30
+ Scripts live in the scope root, beside `memory/`:
31
+
32
+ - `~/.crouter/scripts/` — user-scope: personal workflows (receipts, bookings, account chores).
33
+ - `<project>/.crouter/scripts/` — project-scope: workflows owned by a repo.
34
+
35
+ Layout: one directory per workflow (`scripts/mercury-receipts/`), plus `scripts/lib/` for shared helpers. Every script opens with a header comment:
36
+
37
+ ```js
38
+ // @workflow mercury-receipts — fetch one Anthropic invoice PDF via the invoicedata.stripe.com hop
39
+ // @owner memory/mercury-receipts.md
40
+ ```
41
+
42
+ The header is the discovery contract: it makes scripts greppable and lets tooling list them later without any new runtime machinery. A script with no owning workflow doc is an orphan — every script must be reachable from the doc that explains when to run it.
43
+
44
+ **Default runtime is Node ESM (`.mjs`), executable, no dependencies.** JS keeps scripts importable by each other and by the rest of the ecosystem, and Node's builtins cover the usual needs (`util.parseArgs` for flags, `fetch` for HTTP, `child_process` for CLIs). Reach for another runtime only when JS genuinely cannot do the job (e.g. a Python CDP-websocket edge case) — note why in the header.
45
+
46
+ ## Calling crtr and capture from a script
47
+
48
+ The CLI is the SDK. Every capture command mirrors its result as JSON under `--json`; spawn it and parse:
49
+
50
+ ```js
51
+ import { execFileSync } from 'node:child_process';
52
+ const run = (...args) => JSON.parse(execFileSync('crtr', [...args, '--json'], { encoding: 'utf8' }));
53
+ const tabs = run('capture', 'tab', 'list');
54
+ ```
55
+
56
+ Do not import crouter's internals into a script. crtrd owns canvas state and the CLI is the sanctioned client seam; the exec boundary also keeps scripts version-proof across runtime generations, since they always talk to the installed CLI.
57
+
58
+ Grow `scripts/lib/kit.mjs` as helpers recur — `run()` above, a `pageExec(js, {port, target})` wrapper, a pinned-target guard. The kit is agent-owned and evolves with use; there is no shipped package to wait on.
59
+
60
+ ## Script shape: linear script vs mini-CLI
61
+
62
+ A workflow step with one job is one linear script. But when a workflow has several verbs sharing state and safety rules, build the script *as a small CLI* — subcommands over `util.parseArgs`, noun-verb naming, invariants enforced inside:
63
+
64
+ ```
65
+ recgov pin [target] # persist the exact browser target; refuse ambiguity
66
+ recgov inspect # read login/cart/availability state, never mutate
67
+ recgov add <date> # refuses non-empty cart, wrong site class, unverified login
68
+ recgov fill # fills forms, stops before any payment action
69
+ ```
70
+
71
+ This shape earns its keep exactly when the task has **irreversible edges** (payments, submissions, deletions): the CLI encodes the guard once — verify login before every mutation, refuse a stale port, never touch the purchase button — instead of trusting each future run to remember. The agent building its own harness for a workflow family is the intended endgame of codification, not scope creep.
72
+
73
+ ## Mining guidance
74
+
75
+ - **Auth rides the browser.** If a mined endpoint needs cookies or CSRF tokens, call it *in-page* via `crtr capture page exec` (XHR/fetch runs with the session's credentials). Curl directly only when the request proved public — presigned URLs, tokenized links.
76
+ - **Capture the payload shape, not just the URL.** The HAR's post data is the schema documentation the vendor never published.
77
+ - **Pin targets for anything stateful.** Multiple Chromium apps expose CDP (Electron shells, Spotify, secondary browser profiles). Before mutating anything, verify you are in the right browser *and* the right identity — read a logged-in marker off the page, not just the tab title. `CDP_PORT`/`CDP_TARGET` pin the pair for every subsequent command.
78
+ - **Verify effects like the UI would.** After an upload or submission, re-read the page state that a human would check (the badge count dropped, the row disappeared). A 200 response is not confirmation.
79
+
80
+ ## What good codification looks like
81
+
82
+ The test: a fresh agent, given only `/<name>`, completes the task in a fraction of the original time, refuses the same dangerous states the first derivation learned to refuse, and — when the vendor shipped a redesign overnight — degrades to hands-on capture work instead of failing, then leaves the codification better than it found it.
@@ -0,0 +1,38 @@
1
+ // REGRESSION: ctrl+o (expanded tool output) on a bash call whose command is
2
+ // wider than a narrow pane emitted the command line unclipped, so pi-tui's line
3
+ // accounting desynced from the terminal's own wrapping and the viewer crashed
4
+ // with "Rendered line N exceeds terminal width". Expanded bash now wraps into
5
+ // the pane instead of overhanging it.
6
+ import assert from 'node:assert/strict';
7
+ import test from 'node:test';
8
+ import { visibleWidth } from '@earendil-works/pi-tui';
9
+ import { createCrtrPlainBashToolDefinition } from '../render/tool-calls.js';
10
+ const theme = {
11
+ fg(_name, text) { return text; },
12
+ bg(_name, text) { return text; },
13
+ bold(text) { return text; },
14
+ };
15
+ function renderExpanded(command, width, timeout) {
16
+ const def = createCrtrPlainBashToolDefinition('/tmp');
17
+ const component = def.renderCall({ command, timeout }, theme, { expanded: true, state: {} });
18
+ return component.render(width);
19
+ }
20
+ const COMMANDS = [
21
+ 'ls -la /Users/silasrhyneer/Code/cli/crouter',
22
+ 'grep -rn "averyveryverylongtokenwithnospacesatallxxxxxxxxxxxxxxxxxxxxxx" src/',
23
+ `first\nsecond ${'x'.repeat(120)}`,
24
+ ];
25
+ test('expanded bash call never renders a line wider than the pane', () => {
26
+ for (const width of [80, 42, 20, 3, 2, 1]) {
27
+ for (const command of COMMANDS) {
28
+ for (const line of renderExpanded(command, width, 120)) {
29
+ assert.ok(visibleWidth(line) <= width, `width ${width}: line of ${visibleWidth(line)} cols for ${JSON.stringify(command)}`);
30
+ }
31
+ }
32
+ }
33
+ });
34
+ test('expanded bash wraps rather than clipping — the whole command survives', () => {
35
+ const command = 'ls -la /Users/silasrhyneer/Code/cli/crouter';
36
+ const joined = renderExpanded(command, 42).map((line) => line.trim()).join(' ');
37
+ assert.ok(joined.includes('/Users/silasrhyneer/Code/cli/crouter'), joined);
38
+ });
@@ -62,6 +62,26 @@ function assistantSnapshot(count, tag) {
62
62
  }
63
63
  const countOf = (children, ctor) => children.filter((c) => c instanceof ctor).length;
64
64
  initTheme(undefined, false);
65
+ test('folded tools are separated from preceding assistant prose and thinking', async () => {
66
+ const tui = new TUI(fakeTerminal());
67
+ const container = new Container();
68
+ const view = new ChatView(tui, container, { hideThinking: false });
69
+ const internals = view;
70
+ internals.foldSettledTools = true;
71
+ await view.applySnapshot({
72
+ messages: [
73
+ { role: 'assistant', content: [{ type: 'text', text: 'Prose.' }, { type: 'toolCall', id: 'text-tool', name: 'bash', arguments: { command: 'true' } }] },
74
+ { role: 'toolResult', toolCallId: 'text-tool', toolName: 'bash', content: [{ type: 'text', text: '' }], isError: false },
75
+ { role: 'assistant', content: [{ type: 'thinking', thinking: 'Think.' }, { type: 'toolCall', id: 'thinking-tool', name: 'bash', arguments: { command: 'true' } }] },
76
+ { role: 'toolResult', toolCallId: 'thinking-tool', toolName: 'bash', content: [{ type: 'text', text: '' }], isError: false },
77
+ ],
78
+ stats: {},
79
+ state: { isStreaming: false },
80
+ });
81
+ internals.applyToolDisplay();
82
+ const separators = internals.historyContainer.children.filter((child) => child.render(80).length === 1 && child.render(80)[0] === '');
83
+ assert.equal(separators.length, 2, 'both visible assistant blocks receive a folded-tool gap');
84
+ });
65
85
  test('live bash animation survives history caching and stops on a welcome snapshot', async () => {
66
86
  let renderRequests = 0;
67
87
  const tui = {
@@ -51,5 +51,7 @@ test('the attach viewer covers crtr bash tool calls and crtr-output messages nat
51
51
  assert.match(chatView, /CRTR_OUTPUT_CUSTOM_TYPE/);
52
52
  assert.match(chatView, /new CrtrOutputMessageComponent\(/);
53
53
  assert.match(chatView, /createCrtrBashToolDefinition\(args\)/);
54
- assert.match(chatView, /new ToolExecutionComponent\([\s\S]*toolDefinition as never/);
54
+ // MinimizableToolComponent is the viewer's ToolExecutionComponent subclass (it
55
+ // adds the Ctrl+O minimized state); the crtr tool definition still threads through it.
56
+ assert.match(chatView, /new MinimizableToolComponent\([\s\S]*toolDefinition as never/);
55
57
  });
@@ -1,9 +1,6 @@
1
1
  import { type BashJobStatus } from '../../../core/bash-jobs.js';
2
- /** Compact cockpit row: the running command (or job count) plus elapsed time.
3
- * `undefined` for an empty list — the caller omits the row entirely. */
4
- export declare function bashJobsWaitSummary(jobs: readonly BashJobStatus[], nowMs?: number): string | undefined;
5
- /** Readable drill-in document for the tmux detail surface. Watch-only — no
6
- * cancel line: the owning agent already received the exact `kill -- -<pgid>`
7
- * in its handoff notice, and persisting pgid to add a human stop affordance is
8
- * a separable future addition (measuring-stick restraint). */
9
- export declare function renderBashJobsDetails(jobs: readonly BashJobStatus[], nowMs?: number): string;
2
+ /** Compact cockpit row: state first (matching the cron row), then the running
3
+ * command. `cmdCap` is the columns the caller has left for that command, so the
4
+ * cell shrinks with the pane instead of being clipped after the row's own key
5
+ * hints. `undefined` for an empty list — the caller omits the row entirely. */
6
+ export declare function bashJobsWaitSummary(jobs: readonly BashJobStatus[], nowMs?: number, cmdCap?: number): string | undefined;
@@ -1,43 +1,31 @@
1
- // chrome/bash-jobs.ts — the attach-side formatter pair for live backgrounded bash
2
- // jobs (R3 of the Alt+C -> b bash handoff refinements design), mirroring
3
- // chrome/cron-wait.ts: a compact one-row cockpit summary and a read-only markdown
4
- // detail document. Pure — takes the already-scanned job list, never touches
5
- // disk itself (that's activeBackgroundBashJobs' job, in core/bash-jobs.ts).
1
+ // chrome/bash-jobs.ts — the attach-side formatter for live backgrounded bash jobs,
2
+ // mirroring chrome/cron-wait.ts: the compact one-row cockpit summary. Pure — takes
3
+ // the already-scanned job list, never touches disk itself (that's
4
+ // activeBackgroundBashJobs' job, in core/bash-jobs.ts). Enter on that row opens
5
+ // the Inspector's `jobs` section, which owns the drill-in rendering.
6
6
  import { formatBashElapsed } from '../../../core/bash-jobs.js';
7
- const CMD_CELL_CAP = 26;
8
- /** First line of `command`, whitespace-collapsed and capped to ~26 chars. */
9
- function cmdCell(command) {
7
+ /** First line of `command`, whitespace-collapsed and capped to `cap` columns. A
8
+ * shell one-liner is cut on a word boundary when there is one near the cap, so
9
+ * the cell ends on a readable token instead of mid-`$((`. */
10
+ function cmdCell(command, cap) {
10
11
  const oneLine = command.split('\n')[0].replace(/\s+/g, ' ').trim();
11
- return oneLine.length > CMD_CELL_CAP ? `${oneLine.slice(0, CMD_CELL_CAP - 1)}…` : oneLine;
12
+ if (oneLine.length <= cap)
13
+ return oneLine;
14
+ const cut = oneLine.slice(0, Math.max(1, cap - 1));
15
+ const space = cut.lastIndexOf(' ');
16
+ return `${(space >= cap - 12 ? cut.slice(0, space) : cut).trimEnd()}…`;
12
17
  }
13
- /** Compact cockpit row: the running command (or job count) plus elapsed time.
14
- * `undefined` for an empty list — the caller omits the row entirely. */
15
- export function bashJobsWaitSummary(jobs, nowMs = Date.now()) {
18
+ /** Compact cockpit row: state first (matching the cron row), then the running
19
+ * command. `cmdCap` is the columns the caller has left for that command, so the
20
+ * cell shrinks with the pane instead of being clipped after the row's own key
21
+ * hints. `undefined` for an empty list — the caller omits the row entirely. */
22
+ export function bashJobsWaitSummary(jobs, nowMs = Date.now(), cmdCap = 26) {
16
23
  if (jobs.length === 0)
17
24
  return undefined;
18
25
  if (jobs.length === 1) {
19
26
  const job = jobs[0];
20
- return `${cmdCell(job.command)} · ${formatBashElapsed(nowMs - job.startedAtMs)}`;
27
+ return `bash job · ${formatBashElapsed(nowMs - job.startedAtMs)} · ${cmdCell(job.command, Math.max(12, cmdCap))}`;
21
28
  }
22
29
  const oldest = jobs[0]; // activeBackgroundBashJobs returns oldest-first
23
30
  return `${jobs.length} bash jobs · oldest ${formatBashElapsed(nowMs - oldest.startedAtMs)}`;
24
31
  }
25
- /** Readable drill-in document for the tmux detail surface. Watch-only — no
26
- * cancel line: the owning agent already received the exact `kill -- -<pgid>`
27
- * in its handoff notice, and persisting pgid to add a human stop affordance is
28
- * a separable future addition (measuring-stick restraint). */
29
- export function renderBashJobsDetails(jobs, nowMs = Date.now()) {
30
- const lines = [
31
- '# Background bash jobs',
32
- '',
33
- `${jobs.length} bash job${jobs.length === 1 ? '' : 's'} running in the background.`,
34
- ];
35
- for (const job of jobs) {
36
- lines.push('', `## ⚙ ${cmdCell(job.command)}`, '');
37
- lines.push(`- **Elapsed:** ${formatBashElapsed(nowMs - job.startedAtMs)} · started ${new Date(job.startedAtMs).toISOString()}`);
38
- lines.push(`- **Log:** \`${job.logPath}\``);
39
- lines.push(`- **Watch:** \`tail -f ${job.logPath}\``);
40
- lines.push('', '### Command', '', '```bash', job.command, '```');
41
- }
42
- return `${lines.join('\n')}\n`;
43
- }
@@ -12,7 +12,10 @@
12
12
  // caller owns the Containers and the refresh trigger. beginFrameTelem() clears the
13
13
  // nav-model telemetry cache for this one build pass so token/activity cells are
14
14
  // read fresh for every chrome frame.
15
- import { beginFrameTelem, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, YELLOW, RESET, DIM, GREEN, } from '../../../core/canvas/nav-render.js';
15
+ import { beginFrameTelem, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, fillWidth, YELLOW, RESET, DIM, GREEN, } from '../../../core/canvas/nav-render.js';
16
+ /** Columns the bash row spends on everything except the command itself:
17
+ * ` ⚙ ` + `bash job · ` + elapsed + ` · ` + ` · ↵ details`. */
18
+ const BASH_ROW_CHROME = 34;
16
19
  import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
17
20
  import { cronWaitSummary } from './cron-wait.js';
18
21
  import { bashJobsWaitSummary } from './bash-jobs.js';
@@ -158,10 +161,14 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source, crons
158
161
  items.push({ key: 'human', kind: 'human', line: ` ${YELLOW}⚑ waiting on human · ${selfAsks} pending${RESET}` });
159
162
  // Bash wait row: attention (human) -> active-now (running background work) ->
160
163
  // scheduled-future (armed crons). The leading glyph distinguishes the row type.
161
- const bashSummary = bashJobsWaitSummary(jobs);
164
+ // `x stop` is advertised on the row because it is the roster's only
165
+ // destructive verb and nothing else would teach it. The command cell gets
166
+ // whatever the row's fixed decoration leaves, so the hints always survive.
167
+ const stopHint = jobs.length === 1 ? ' · x stop' : '';
168
+ const bashSummary = bashJobsWaitSummary(jobs, Date.now(), fillWidth() - (BASH_ROW_CHROME + stopHint.length));
162
169
  if (bashSummary !== undefined) {
163
170
  const gear = palette ? palette.info('⚙') : '⚙';
164
- items.push({ key: 'bash', kind: 'bash', line: ` ${gear} ${muted(`${bashSummary} · ↵ details`)}${RESET}` });
171
+ items.push({ key: 'bash', kind: 'bash', line: ` ${gear} ${muted(`${bashSummary} · ↵ details${stopHint}`)}${RESET}` });
165
172
  }
166
173
  const cronSummary = cronWaitSummary(crons);
167
174
  if (cronSummary !== undefined) {
@@ -5,14 +5,20 @@ export interface RosterHooks {
5
5
  /** Live per-node human-ticket counts — read fresh on every rebuild, since the
6
6
  * viewer's attention poll reassigns the map behind us. */
7
7
  asks: () => Record<string, number>;
8
- /** Transient footer notice (errors, "already fired", swap failures). */
9
- setNotice: (msg: string) => void;
8
+ /** Transient footer notice (errors, "already fired", swap failures). A
9
+ * `sticky` notice holds until it is replaced or cleared — what the `x` stop
10
+ * confirmation needs, since the ordinary notice fades in three seconds and a
11
+ * question that vanished must not still be armed. */
12
+ setNotice: (msg: string, opts?: {
13
+ sticky?: boolean;
14
+ }) => void;
10
15
  /** Ask the viewer to re-run the full chrome refresh. Detail popups call this
11
16
  * on close so a wait row that resolved while the popup was open disappears. */
12
17
  requestChromeRefresh: () => void;
13
18
  /** Open the viewer-owned Inspector popup (`surface inspect`) on a section —
14
- * the cron wait row routes to `schedule`; the native Inspector owns details. */
15
- openInspector: (section: 'overview' | 'schedule') => void;
19
+ * the cron wait row routes to `schedule` and the bash row to `jobs`; the
20
+ * native Inspector owns details for both. */
21
+ openInspector: (section: 'overview' | 'schedule' | 'jobs') => void;
16
22
  }
17
23
  export interface AttachRoster {
18
24
  /** Rebuild the rows from the canvas source, reconcile the selection, repaint. */
@@ -4,9 +4,11 @@
4
4
  // editor's visual bottom drops the cursor into the list; within it ↑/↓ move the
5
5
  // highlight, ↑ off the TOP row returns to the editor, ↓ CLAMPS at the bottom,
6
6
  // Enter opens the row (SWAPS this pane in place onto a node, or opens the wait
7
- // detail surface for a human/cron/bash row), Esc / any typed key returns to the
8
- // editor. Disabled entirely for a REMOTE attach: a remote graph id has no local
9
- // pane to swap into and no local jobs/crons to inspect.
7
+ // detail surface for a human/cron/bash row: a human row opens the inbox, a cron
8
+ // row the Inspector's schedule section, a bash row the Inspector's jobs section),
9
+ // Esc / any typed key returns to the editor. Disabled entirely for a REMOTE
10
+ // attach: a remote graph id has no local pane to swap into and no local
11
+ // jobs/crons to inspect.
10
12
  //
11
13
  // TWO EDITOR COUPLINGS ARE OWNED HERE, deliberately and exclusively:
12
14
  // • `editor.onCursorDownAtBottom` — the boundary handoff. The editor owns
@@ -23,18 +25,12 @@
23
25
  // graph, so `refresh()` rebuilds the cached rows asynchronously while arrow
24
26
  // movement only reverses the selected cached row and renders immediately.
25
27
  import { execFile, execFileSync } from 'node:child_process';
26
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
29
- import { Text, matchesKey } from '@earendil-works/pi-tui';
28
+ import { Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
30
29
  import { cliClient } from '../../../commands/api-client.js';
31
30
  import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
32
31
  import { contextDir } from '../../../core/canvas/paths.js';
33
- import { fillBar, fillWidth, REVERSE } from '../../../core/canvas/nav-render.js';
34
- import { inTmux, shellQuote } from '../../../core/runtime/placement-tmux.js';
35
- import { surfaceTmuxStyleArgs } from '../../../core/runtime/surface-bg.js';
32
+ import { fillBar, fillWidth, stripSgr, REVERSE } from '../../../core/canvas/nav-render.js';
36
33
  import { buildCanvasPanelLines } from './canvas-panels.js';
37
- import { renderBashJobsDetails } from './bash-jobs.js';
38
34
  /** Seed pane focus from the pane's REAL active state, not an unconditional
39
35
  * `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
40
36
  * spawned into a non-active pane (a `split -d` beside another node — the
@@ -54,6 +50,8 @@ function initialPaneActive() {
54
50
  return true;
55
51
  }
56
52
  }
53
+ /** How long an armed `x` waits for its `y`. */
54
+ const ARM_TIMEOUT_MS = 15_000;
57
55
  export function createRoster(s, hooks) {
58
56
  const { editor, tui, pal, nodeId, remote, role, canvasSource } = s;
59
57
  const reports = s.containers.reports;
@@ -65,6 +63,12 @@ export function createRoster(s, hooks) {
65
63
  if (autoSelect !== undefined)
66
64
  delete process.env['CRTR_ATTACH_ROSTER'];
67
65
  let paneFocused = initialPaneActive();
66
+ // Armed job cancellation: `x` on the bash row names the job, `y` confirms.
67
+ // Held here (not in the item list) so any other key, a selection move, or a
68
+ // rebuild that retires the job drops it — a stale confirmation must never
69
+ // kill a job the user is no longer looking at.
70
+ let pendingKill;
71
+ let killTimer;
68
72
  const syncCursorDim = () => {
69
73
  editor.paneFocused = paneFocused && selKey === undefined;
70
74
  };
@@ -73,7 +77,10 @@ export function createRoster(s, hooks) {
73
77
  reports.clear();
74
78
  for (const item of items) {
75
79
  const selected = item.key === selKey;
76
- reports.addChild(new Text(selected ? fillBar(item.line, fillWidth(), REVERSE) : item.line, 1, 0));
80
+ // Selected rows highlight as ONE uniform bar: strip the row's own colours
81
+ // first, because reverse-video turns every tinted span into a coloured
82
+ // block and the bar reads as a patchwork otherwise.
83
+ reports.addChild(new Text(selected ? fillBar(stripSgr(item.line), fillWidth(), REVERSE) : item.line, 1, 0));
77
84
  }
78
85
  tui.requestRender();
79
86
  };
@@ -100,44 +107,78 @@ export function createRoster(s, hooks) {
100
107
  if (selKey !== undefined && !items.some((item) => item.key === selKey)) {
101
108
  selKey = undefined;
102
109
  }
110
+ if (pendingKill !== undefined && !bashJobs.some((job) => job.jobId === pendingKill?.jobId))
111
+ disarmKill(true);
103
112
  paint();
104
113
  };
114
+ /** Drop an armed stop, and the question with it. */
115
+ const disarmKill = (clearNotice) => {
116
+ if (killTimer !== undefined) {
117
+ clearTimeout(killTimer);
118
+ killTimer = undefined;
119
+ }
120
+ if (pendingKill === undefined)
121
+ return;
122
+ pendingKill = undefined;
123
+ if (clearNotice)
124
+ hooks.setNotice('');
125
+ };
105
126
  const exit = () => {
127
+ disarmKill(true);
106
128
  if (selKey !== undefined) {
107
129
  selKey = undefined;
108
130
  paint();
109
131
  }
110
132
  };
111
- editor.onCursorDownAtBottom = remote ? undefined : () => {
112
- if (selKey !== undefined || items.length === 0)
113
- return;
114
- selKey = items[0].key;
115
- paint();
133
+ /** The printable character a keystroke carries, under both plain and Kitty
134
+ * encodings — the roster's own `x`/`y` verbs are letters, not chords. */
135
+ const typed = (data) => decodeKittyPrintable(data) ?? (data.length === 1 ? data : undefined);
136
+ /** First line of a command, for the confirmation prompt. */
137
+ const commandHead = (command, cap = 40) => {
138
+ const line = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
139
+ return line.length > cap ? `${line.slice(0, cap - 1)}…` : line;
116
140
  };
117
- /** Open a read-only markdown detail popup in nvim, deleting the temp file when
118
- * the popup closes and re-running the chrome refresh (background-bash wait row). */
119
- const openDetailsPopup = (prefix, file, title, missingNvim, body) => {
120
- const dir = mkdtempSync(join(tmpdir(), prefix));
121
- const path = join(dir, file);
122
- writeFileSync(path, body, 'utf8');
123
- const cmd = `if command -v nvim >/dev/null 2>&1; then exec nvim -R -c 'setlocal filetype=markdown' -- ${shellQuote(path)}; else printf '${missingNvim}\\n'; printf 'Press Enter to close.'; read _; fi`;
124
- execFile('tmux', ['display-popup', '-E', '-T', title, '-w', '90%', '-h', '85%', ...surfaceTmuxStyleArgs(), cmd], () => {
125
- rmSync(dir, { recursive: true, force: true });
126
- hooks.requestChromeRefresh();
127
- });
128
- };
129
- const openBashJobsDetails = () => {
130
- const current = activeBackgroundBashJobs(contextDir(nodeId));
131
- if (current.length === 0) {
141
+ /** `x` on the bash row: arm cancellation for the one live job. With several
142
+ * jobs there is no single target on a summary row, so send the user into the
143
+ * Inspector, where each job is separately selectable. */
144
+ const armKill = () => {
145
+ if (role !== 'controller') {
146
+ hooks.setNotice('Read-only observer attach — cannot stop background jobs');
147
+ return;
148
+ }
149
+ const jobs = activeBackgroundBashJobs(contextDir(nodeId));
150
+ if (jobs.length === 0) {
132
151
  hooks.setNotice('Background job already finished');
133
152
  hooks.requestChromeRefresh();
134
153
  return;
135
154
  }
136
- if (!inTmux()) {
137
- hooks.setNotice('Background job details require tmux');
155
+ if (jobs.length > 1) {
156
+ hooks.setNotice(`${jobs.length} background jobs — press ↵ to pick one in the Inspector`);
138
157
  return;
139
158
  }
140
- openDetailsPopup('crtr-bash-jobs-', 'background-bash-jobs.md', ' background bash jobs ', 'nvim is required to inspect background bash jobs. Install Neovim and retry.', renderBashJobsDetails(current));
159
+ const job = jobs[0];
160
+ pendingKill = { jobId: job.jobId, label: commandHead(job.command) };
161
+ hooks.setNotice(`Stop “${pendingKill.label}”? Press y to confirm.`, { sticky: true });
162
+ // An unanswered question expires rather than sitting armed forever — a `y`
163
+ // typed minutes later belongs to whatever the user is doing then.
164
+ killTimer = setTimeout(() => disarmKill(true), ARM_TIMEOUT_MS);
165
+ };
166
+ const confirmKill = () => {
167
+ const target = pendingKill;
168
+ if (target === undefined)
169
+ return;
170
+ disarmKill(false);
171
+ hooks.setNotice(`Stopping “${target.label}”…`);
172
+ execFile('crtr', ['node', 'bash', 'kill', target.jobId, '--node', nodeId], (err) => {
173
+ hooks.setNotice(err === null ? `Stopped “${target.label}”.` : `Could not stop the job: ${err.message.split('\n')[0]}`);
174
+ hooks.requestChromeRefresh();
175
+ });
176
+ };
177
+ editor.onCursorDownAtBottom = remote ? undefined : () => {
178
+ if (selKey !== undefined || items.length === 0)
179
+ return;
180
+ selKey = items[0].key;
181
+ paint();
141
182
  };
142
183
  const openSelected = () => {
143
184
  const item = items.find((candidate) => candidate.key === selKey);
@@ -158,8 +199,13 @@ export function createRoster(s, hooks) {
158
199
  return;
159
200
  }
160
201
  if (item.kind === 'bash') {
202
+ if (activeBackgroundBashJobs(contextDir(nodeId)).length === 0) {
203
+ hooks.setNotice('Background job already finished');
204
+ hooks.requestChromeRefresh();
205
+ return;
206
+ }
161
207
  exit();
162
- openBashJobsDetails();
208
+ hooks.openInspector('jobs');
163
209
  return;
164
210
  }
165
211
  const id = item.nodeId;
@@ -196,6 +242,20 @@ export function createRoster(s, hooks) {
196
242
  // Roster active. Explicit boundaries on the single list: ↑ off the TOP row
197
243
  // returns to the editor; ↓ CLAMPS at the bottom row.
198
244
  const i = items.findIndex((item) => item.key === selKey);
245
+ const selected = items[i];
246
+ // Confirmation first: while a kill is armed, `y` fires it and every other
247
+ // key disarms it before taking its normal meaning.
248
+ if (pendingKill !== undefined) {
249
+ if (typed(data) === 'y') {
250
+ confirmKill();
251
+ return true;
252
+ }
253
+ disarmKill(true);
254
+ }
255
+ if (selected?.kind === 'bash' && typed(data) === 'x') {
256
+ armKill();
257
+ return true;
258
+ }
199
259
  if (up) {
200
260
  if (i <= 0)
201
261
  exit();
@@ -48,6 +48,9 @@ export interface InputControllerHooks {
48
48
  /** OPTIONAL: Ctrl+O / app.tools.expand — flip global tool-output expansion in
49
49
  * the render layer (ChatView owns the components). Absent → the key no-ops. */
50
50
  onToggleToolsExpand?: () => void;
51
+ /** OPTIONAL: `/fold-tools` — flip the "fold finished tool calls" view setting.
52
+ * Absent → the command notifies it is unwired. */
53
+ onFoldTools?: () => void;
51
54
  /** OPTIONAL: Ctrl+T / app.thinking.toggle — flip thinking-block visibility in
52
55
  * the render layer. Absent → the key no-ops. */
53
56
  onToggleThinking?: () => void;
@@ -244,6 +244,7 @@ export class InputController {
244
244
  onCopy: this.hooks.onCopy,
245
245
  onColor: this.hooks.onColor,
246
246
  onNodeMetadata: this.hooks.onNodeMetadata,
247
+ onFoldTools: this.hooks.onFoldTools,
247
248
  openModelPicker: () => void this.openModelPicker(),
248
249
  openSessionPicker: () => void this.openSessionPicker(),
249
250
  openForkPicker: () => void this.openForkPicker(),
@@ -0,0 +1,13 @@
1
+ import { AssistantMessageComponent } from '@earendil-works/pi-coding-agent';
2
+ type AssistantMessageLike = Parameters<AssistantMessageComponent['updateContent']>[0];
3
+ export declare class ViewerAssistantMessageComponent extends AssistantMessageComponent {
4
+ /** The message as it arrived, thinking parts intact. */
5
+ private original;
6
+ /** The stripped copy last handed to the base class, for identity comparison. */
7
+ private stripped;
8
+ private hidden;
9
+ constructor(message: AssistantMessageLike | undefined, hideThinkingBlock?: boolean, markdownTheme?: ConstructorParameters<typeof AssistantMessageComponent>[2], hiddenThinkingLabel?: string);
10
+ updateContent(message: AssistantMessageLike): void;
11
+ setHideThinkingBlock(hide: boolean): void;
12
+ }
13
+ export {};