@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.
- package/dist/api/dto/nodes.d.ts +3 -0
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-memory/internal/workflow-codification.md +82 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -8
- package/dist/clients/attach/chrome/bash-jobs.js +20 -32
- package/dist/clients/attach/chrome/canvas-panels.js +10 -3
- package/dist/clients/attach/chrome/roster.d.ts +10 -4
- package/dist/clients/attach/chrome/roster.js +96 -36
- package/dist/clients/attach/input/controller.d.ts +3 -0
- package/dist/clients/attach/input/controller.js +1 -0
- package/dist/clients/attach/render/assistant-message.d.ts +13 -0
- package/dist/clients/attach/render/assistant-message.js +55 -0
- package/dist/clients/attach/render/chat-view.d.ts +29 -0
- package/dist/clients/attach/render/chat-view.js +233 -12
- package/dist/clients/attach/render/context-message.js +1 -1
- package/dist/clients/attach/render/edit-diff.js +20 -1
- package/dist/clients/attach/render/tool-calls.d.ts +32 -0
- package/dist/clients/attach/render/tool-calls.js +251 -0
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +3 -0
- package/dist/clients/attach/slash/dispatch.d.ts +4 -0
- package/dist/clients/attach/slash/dispatch.js +8 -0
- package/dist/clients/attach/viewer.js +568 -566
- package/dist/commands/cron.js +2 -2
- package/dist/commands/node.js +141 -3
- package/dist/commands/search.js +1 -1
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/commands/sys/config.js +10 -2
- package/dist/commands/sys/doctor.js +22 -1
- package/dist/commands/sys/setup-core.d.ts +5 -1
- package/dist/commands/sys/setup-core.js +16 -2
- package/dist/commands/sys/setup-wizard.d.ts +11 -0
- package/dist/commands/sys/setup-wizard.js +111 -6
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/tmux-surface.test.js +5 -2
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/labels.d.ts +1 -0
- package/dist/core/canvas/labels.js +3 -1
- package/dist/core/canvas/nav-render.d.ts +6 -0
- package/dist/core/canvas/nav-render.js +8 -0
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/config.js +3 -2
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +5 -2
- package/dist/core/preview-registry.d.ts +5 -0
- package/dist/core/preview-registry.js +10 -0
- package/dist/core/runtime/canvas-extensions.d.ts +4 -0
- package/dist/core/runtime/canvas-extensions.js +4 -0
- package/dist/core/runtime/naming-persist.d.ts +4 -3
- package/dist/core/runtime/naming-persist.js +10 -9
- package/dist/core/runtime/naming.d.ts +28 -8
- package/dist/core/runtime/naming.js +168 -32
- package/dist/core/runtime/nerd-font.d.ts +28 -0
- package/dist/core/runtime/nerd-font.js +127 -0
- package/dist/core/runtime/tmux.js +16 -0
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/map.js +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +5 -0
- package/dist/pi-extensions/naming-tool.d.ts +24 -0
- package/dist/pi-extensions/naming-tool.js +67 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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:
|
|
3
|
-
* `
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
14
|
-
* `
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
/**
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 (
|
|
137
|
-
hooks.setNotice(
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|