@north-light/crouter 0.3.277 → 0.3.279
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/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +11 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +11 -3
- package/dist/clients/attach/chrome/status-line.js +1 -22
- package/dist/clients/attach/input/capabilities.js +2 -1
- package/dist/clients/attach/render/chat-view.d.ts +11 -0
- package/dist/clients/attach/render/chat-view.js +23 -4
- package/dist/clients/attach/render/tool-calls.d.ts +6 -1
- package/dist/clients/attach/render/tool-calls.js +47 -3
- package/dist/clients/attach/session/bindings.d.ts +1 -1
- package/dist/clients/attach/session/bindings.js +1 -1
- package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
- package/dist/clients/attach/session/chrome-refresh.js +3 -3
- package/dist/clients/attach/slash/dispatch.js +42 -19
- package/dist/clients/attach/viewer.js +564 -564
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/node/bash.js +5 -8
- package/dist/core/__tests__/helpers/fleet.d.ts +1 -1
- package/dist/core/__tests__/helpers/fleet.js +5 -0
- package/dist/core/__tests__/helpers/harness.js +5 -1
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +32 -7
- package/dist/core/canvas/browse/render.d.ts +17 -2
- package/dist/core/canvas/browse/render.js +342 -242
- package/dist/core/canvas/render-source.d.ts +26 -0
- package/dist/core/canvas/render-source.js +73 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/runtime/busy.d.ts +13 -0
- package/dist/core/runtime/busy.js +43 -1
- package/dist/core/runtime/revive.js +2 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/daemon/fleet.d.ts +3 -0
- package/dist/daemon/fleet.js +35 -5
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +14 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +18 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -4,8 +4,9 @@ import { anthropicMessagesApi, openAICodexResponsesApi } from '@earendil-works/p
|
|
|
4
4
|
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, INVALID_REFRESH_TOKEN_BACKOFF_MS, formatStatusLine, getProviderLabel, isManagedProvider, isInvalidRefreshTokenError, addSubscription, enrichManagedLoginIdentity, markSubscriptionAuthenticationFailed, markSubscriptionRateLimited, markSubscriptionSuccess, parseRetryAfterHeader, promoteSubscription, readRotationConfig, readSubscriptionPool, refreshSubscriptionCredential, removeManagedAccount, resolveProviderCandidates, upsertSubscriptionWithUniqueAccount, } from '../../../core/subscription-state.js';
|
|
5
5
|
import { readMergedLaunchConfig } from '../../../core/config.js';
|
|
6
6
|
import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
|
|
7
|
-
import { envModelExact } from '../../../shared/env.js';
|
|
7
|
+
import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
8
8
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
9
|
+
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
9
10
|
import { recognizeProviderError } from '../../../core/provider-error-recognition.js';
|
|
10
11
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
11
12
|
// does not authorize crouter to invent a five-minute provider-wide outage. Keep
|
|
@@ -102,8 +103,10 @@ export function __setSleepForTest(fn) {
|
|
|
102
103
|
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
103
104
|
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
104
105
|
// respawn this broker against the same session. A genuine full network outage
|
|
105
|
-
//
|
|
106
|
-
//
|
|
106
|
+
// trips this too, and a fresh process cannot heal that one — so the daemon's
|
|
107
|
+
// respawn path probes network reachability before it counts a relaunch, and an
|
|
108
|
+
// offline box parks the node until the network returns instead of spending its
|
|
109
|
+
// respawn budget (daemon/fleet.ts, `deferRespawnWhileOffline`).
|
|
107
110
|
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
108
111
|
function defaultTerminatePoisonedProcess(tried) {
|
|
109
112
|
// Only the broker process itself may self-terminate: runBroker stamps
|
|
@@ -111,6 +114,11 @@ function defaultTerminatePoisonedProcess(tried) {
|
|
|
111
114
|
// keeps the ordinary terminal-error path.
|
|
112
115
|
if (process.env["CRTR_BROKER_PID"] !== String(process.pid))
|
|
113
116
|
return false;
|
|
117
|
+
const routes = tried.split(', ').filter((route) => route !== '');
|
|
118
|
+
const nodeId = envNodeId();
|
|
119
|
+
if (nodeId !== undefined && routes.length > 0) {
|
|
120
|
+
recordTransportPoisonVerdict(nodeId, { consecutivePasses: POISONED_TRANSPORT_PASS_LIMIT, routes });
|
|
121
|
+
}
|
|
114
122
|
logRotation(`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`);
|
|
115
123
|
process.kill(process.pid, "SIGTERM");
|
|
116
124
|
return true;
|
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
} from '../../../core/subscription-state.js';
|
|
40
40
|
import { readMergedLaunchConfig } from '../../../core/config.js';
|
|
41
41
|
import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
|
|
42
|
-
import { envModelExact } from '../../../shared/env.js';
|
|
42
|
+
import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
43
43
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
44
|
+
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
44
45
|
import { recognizeProviderError, type ProviderErrorSignal } from '../../../core/provider-error-recognition.js';
|
|
45
46
|
|
|
46
47
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
@@ -197,8 +198,10 @@ export function __setSleepForTest(fn: typeof defaultSleep | undefined): void {
|
|
|
197
198
|
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
198
199
|
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
199
200
|
// respawn this broker against the same session. A genuine full network outage
|
|
200
|
-
//
|
|
201
|
-
//
|
|
201
|
+
// trips this too, and a fresh process cannot heal that one — so the daemon's
|
|
202
|
+
// respawn path probes network reachability before it counts a relaunch, and an
|
|
203
|
+
// offline box parks the node until the network returns instead of spending its
|
|
204
|
+
// respawn budget (daemon/fleet.ts, `deferRespawnWhileOffline`).
|
|
202
205
|
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
203
206
|
|
|
204
207
|
function defaultTerminatePoisonedProcess(tried: string): boolean {
|
|
@@ -206,6 +209,11 @@ function defaultTerminatePoisonedProcess(tried: string): boolean {
|
|
|
206
209
|
// CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
|
|
207
210
|
// keeps the ordinary terminal-error path.
|
|
208
211
|
if (process.env["CRTR_BROKER_PID"] !== String(process.pid)) return false;
|
|
212
|
+
const routes = tried.split(', ').filter((route) => route !== '');
|
|
213
|
+
const nodeId = envNodeId();
|
|
214
|
+
if (nodeId !== undefined && routes.length > 0) {
|
|
215
|
+
recordTransportPoisonVerdict(nodeId, { consecutivePasses: POISONED_TRANSPORT_PASS_LIMIT, routes });
|
|
216
|
+
}
|
|
209
217
|
logRotation(
|
|
210
218
|
`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`,
|
|
211
219
|
);
|
|
@@ -18,15 +18,10 @@
|
|
|
18
18
|
// explicitly replaced (the reconnect indicator, cleared by hand on redial).
|
|
19
19
|
// That timer is why this module has a `dispose`.
|
|
20
20
|
import { Text, truncateToWidth } from '@earendil-works/pi-tui';
|
|
21
|
-
import { oldestRunningBashJobStartedAtMs } from '../../../core/bash-jobs.js';
|
|
22
|
-
import { contextDir } from '../../../core/canvas/paths.js';
|
|
23
21
|
import { visibleWidth } from '../../../core/canvas/nav-render.js';
|
|
24
22
|
import { inTmux } from '../../../core/runtime/placement-tmux.js';
|
|
25
|
-
import {
|
|
23
|
+
import { formatBinding, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
26
24
|
const NOTICE_CLEAR_MS = 3_000;
|
|
27
|
-
/** R4 (Alt+C -> b hint): how long a foreground bash command must have been
|
|
28
|
-
* running before backgrounding it is worth suggesting. */
|
|
29
|
-
const BASH_HINT_THRESHOLD_MS = 5_000;
|
|
30
25
|
const formatTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`;
|
|
31
26
|
export function createStatusLine(s, hooks) {
|
|
32
27
|
const { pal, tui, nodeId, remote } = s;
|
|
@@ -81,22 +76,6 @@ export function createStatusLine(s, hooks) {
|
|
|
81
76
|
if (!hooks.mouseEnabled()) {
|
|
82
77
|
segs.push(pal.muted('mouse off'));
|
|
83
78
|
}
|
|
84
|
-
// Lowest-priority segment (R4): the Alt+C -> b hint, shown only once the
|
|
85
|
-
// oldest still-foreground bash job has crossed the threshold. Read straight
|
|
86
|
-
// off this node's job files rather than tracked from relayed tool-execution
|
|
87
|
-
// events — that on-disk state is the valve's own authoritative record of
|
|
88
|
-
// what is really running, so a fresh or reconnected attach (which never
|
|
89
|
-
// replays past events) reads it correctly with no viewer-local map that
|
|
90
|
-
// could go stale across a broker/session replacement. Evaluated per paint
|
|
91
|
-
// and on the viewer's existing 5s chrome poll — no new timer.
|
|
92
|
-
if (!remote && inTmux()) {
|
|
93
|
-
const oldestStart = oldestRunningBashJobStartedAtMs(contextDir(nodeId));
|
|
94
|
-
if (oldestStart !== undefined && Date.now() - oldestStart >= BASH_HINT_THRESHOLD_MS) {
|
|
95
|
-
const hint = backgroundBashHint(bindings);
|
|
96
|
-
if (hint !== null)
|
|
97
|
-
segs.push(pal.muted(`⧗ ${hint}`));
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
79
|
// The file-review affordance, tmux-only like the surface it opens.
|
|
101
80
|
if (inTmux() && bindings.gestures('crtr.attach.file-review').length > 0) {
|
|
102
81
|
segs.push(pal.muted(`${formatBinding(bindings, 'crtr.attach.file-review')} review file`));
|
|
@@ -74,7 +74,7 @@ export function viewerOperationForFrame(frame) {
|
|
|
74
74
|
export function viewerOperationForSlash(name, builtin, canvas) {
|
|
75
75
|
if (canvas) {
|
|
76
76
|
switch (name) {
|
|
77
|
-
case 'resume
|
|
77
|
+
case 'resume': return 'canvas-resume';
|
|
78
78
|
case 'context':
|
|
79
79
|
case 'node-metadata': return 'local-canvas-inspect';
|
|
80
80
|
// `/plugins` configures the local machine, not this node; observers and
|
|
@@ -84,6 +84,7 @@ export function viewerOperationForSlash(name, builtin, canvas) {
|
|
|
84
84
|
case 'clear':
|
|
85
85
|
case 'promote':
|
|
86
86
|
case 'rename':
|
|
87
|
+
case 'resume-session':
|
|
87
88
|
case 'mcp': return 'engine-drive';
|
|
88
89
|
default: return undefined;
|
|
89
90
|
}
|
|
@@ -58,6 +58,10 @@ export interface ChatViewOptions {
|
|
|
58
58
|
/** How the person opens their inbox on this surface, resolved lazily because
|
|
59
59
|
* bindings are resolved after ChatView is constructed. */
|
|
60
60
|
pageOpenHint?: () => string;
|
|
61
|
+
/** How the person hands a still-running bash command to the background on this
|
|
62
|
+
* surface, resolved lazily for the same reason. Null when this viewer cannot
|
|
63
|
+
* invoke the handoff at all, which suppresses the row. */
|
|
64
|
+
backgroundBashHint?: () => string | null;
|
|
61
65
|
/** Pre-colored banner lines pinned as the FIRST child of the chat container,
|
|
62
66
|
* above history (the crouton wordmark). Sits at the very top of the transcript
|
|
63
67
|
* and scrolls up into scrollback as the chat grows. Absent → no banner. */
|
|
@@ -85,6 +89,7 @@ export declare class ChatView {
|
|
|
85
89
|
private readonly pageBlocks;
|
|
86
90
|
private readonly inlinePageBlocks;
|
|
87
91
|
private readonly pageOpenHint;
|
|
92
|
+
private readonly backgroundBashHint;
|
|
88
93
|
/** Trailing cycles kept at full fidelity (config `live_cycles`). */
|
|
89
94
|
private readonly liveCycles;
|
|
90
95
|
/** Messages kept from a condensed cycle (config `condensed_history`). */
|
|
@@ -293,6 +298,12 @@ export declare class ChatView {
|
|
|
293
298
|
* way assistant prose stays. Condensed history is skipped deliberately — old
|
|
294
299
|
* cycles keep only prose and cycle seams. */
|
|
295
300
|
private maybeAppendPageBlock;
|
|
301
|
+
/** Re-measure the tool calls still in flight. A silent long-running command
|
|
302
|
+
* emits no tool-execution update, so nothing else would evict its cached
|
|
303
|
+
* lines and its backgrounding hint would never appear. Driven by the
|
|
304
|
+
* viewer's existing low-rate chrome poll, and cheap because it touches only
|
|
305
|
+
* in-flight tools — usually one, never the transcript. */
|
|
306
|
+
refreshInFlightTools(): void;
|
|
296
307
|
/** Re-read every placed page from disk. Driven by the viewer's ticket-activity
|
|
297
308
|
* watcher, so an answer, a cancellation, or a `--replace` revision lands on
|
|
298
309
|
* the block already anchored in the transcript. */
|
|
@@ -35,7 +35,7 @@ import { styleAttachAgentMarkdown, styleAttachMarkdownSource, styleAttachMessage
|
|
|
35
35
|
import { createCrtrEditToolDefinition } from './edit-diff.js';
|
|
36
36
|
import { GroupActivityRecorder } from './group-activity.js';
|
|
37
37
|
import { GroupRecapComponent } from './group-recap.js';
|
|
38
|
-
import { FoldedToolCallController, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
|
|
38
|
+
import { FoldedToolCallController, bashCallBackgroundHintLines, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
|
|
39
39
|
import { ringCompletionBell } from '../chrome/completion-bell.js';
|
|
40
40
|
import { extractFilePaths } from '#core/human/visible-paths';
|
|
41
41
|
import { keepWhenCondensed, liveRegionStart } from './condensed-history.js';
|
|
@@ -225,8 +225,13 @@ class MinimizableToolComponent extends ToolExecutionComponent {
|
|
|
225
225
|
super.setExpanded(this.expandedState);
|
|
226
226
|
}
|
|
227
227
|
render(width) {
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
// The backgrounding row is appended HERE, by the component that owns the
|
|
229
|
+
// whole tool block, so it renders after the block's highlighted band ends
|
|
230
|
+
// instead of inside it. It is empty unless this is a bash call still running
|
|
231
|
+
// past the threshold.
|
|
232
|
+
if (!this.minimized) {
|
|
233
|
+
return [...super.render(width), ...bashCallBackgroundHintLines(this.foldController?.callComponent(), width)];
|
|
234
|
+
}
|
|
230
235
|
const call = this.foldController?.callComponent();
|
|
231
236
|
if (!call)
|
|
232
237
|
return super.render(width);
|
|
@@ -276,6 +281,7 @@ export class ChatView {
|
|
|
276
281
|
pageBlocks = new Map();
|
|
277
282
|
inlinePageBlocks;
|
|
278
283
|
pageOpenHint;
|
|
284
|
+
backgroundBashHint;
|
|
279
285
|
/** Trailing cycles kept at full fidelity (config `live_cycles`). */
|
|
280
286
|
liveCycles;
|
|
281
287
|
/** Messages kept from a condensed cycle (config `condensed_history`). */
|
|
@@ -391,6 +397,7 @@ export class ChatView {
|
|
|
391
397
|
this.onActivityChange = opts.onActivityChange;
|
|
392
398
|
this.inlinePageBlocks = opts.inlinePageBlocks ?? false;
|
|
393
399
|
this.pageOpenHint = opts.pageOpenHint ?? (() => 'crtr human list');
|
|
400
|
+
this.backgroundBashHint = opts.backgroundBashHint ?? (() => null);
|
|
394
401
|
this.recapPalette = opts.palette ?? defaultAttachPalette;
|
|
395
402
|
this.spinnerStyle = this.recapPalette.active;
|
|
396
403
|
this.dimStyle = this.recapPalette.muted;
|
|
@@ -1380,7 +1387,7 @@ export class ChatView {
|
|
|
1380
1387
|
let resultObserver;
|
|
1381
1388
|
let toolDefinition;
|
|
1382
1389
|
if (name === 'bash') {
|
|
1383
|
-
toolDefinition = createCrtrBashToolDefinition(args, fold) ?? createCrtrPlainBashToolDefinition(this.cwd, fold);
|
|
1390
|
+
toolDefinition = createCrtrBashToolDefinition(args, fold) ?? createCrtrPlainBashToolDefinition(this.cwd, fold, this.backgroundBashHint);
|
|
1384
1391
|
}
|
|
1385
1392
|
else if (name === 'edit') {
|
|
1386
1393
|
const edit = createCrtrEditToolDefinition(this.cwd, fold);
|
|
@@ -1444,6 +1451,18 @@ export class ChatView {
|
|
|
1444
1451
|
this.pageBlocks.set(page.pageId, block);
|
|
1445
1452
|
this.append(block);
|
|
1446
1453
|
}
|
|
1454
|
+
/** Re-measure the tool calls still in flight. A silent long-running command
|
|
1455
|
+
* emits no tool-execution update, so nothing else would evict its cached
|
|
1456
|
+
* lines and its backgrounding hint would never appear. Driven by the
|
|
1457
|
+
* viewer's existing low-rate chrome poll, and cheap because it touches only
|
|
1458
|
+
* in-flight tools — usually one, never the transcript. */
|
|
1459
|
+
refreshInFlightTools() {
|
|
1460
|
+
if (this.pendingTools.size === 0)
|
|
1461
|
+
return;
|
|
1462
|
+
for (const component of this.pendingTools.values())
|
|
1463
|
+
this.historyContainer.markDirty(component);
|
|
1464
|
+
this.tui.requestRender();
|
|
1465
|
+
}
|
|
1447
1466
|
/** Re-read every placed page from disk. Driven by the viewer's ticket-activity
|
|
1448
1467
|
* watcher, so an answer, a cancellation, or a `--replace` revision lands on
|
|
1449
1468
|
* the block already anchored in the transcript. */
|
|
@@ -28,6 +28,11 @@ export declare function createCrtrReadToolDefinition(cwd: string, fold?: FoldedT
|
|
|
28
28
|
* syntax-highlighted content preview and its expand hint) with the icon
|
|
29
29
|
* prefixed onto its header line. Folded, only that header survives. */
|
|
30
30
|
export declare function createCrtrWriteToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
|
|
31
|
+
/** The backgrounding row for a tool component's captured call — nothing unless
|
|
32
|
+
* that call is a bash command still running past the threshold. The owning tool
|
|
33
|
+
* component renders it after its whole block, which is what keeps the row
|
|
34
|
+
* outside the highlighted band. */
|
|
35
|
+
export declare function bashCallBackgroundHintLines(call: Component | undefined, width: number): string[];
|
|
31
36
|
/** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
|
|
32
|
-
export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
|
|
37
|
+
export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController, backgroundHint?: () => string | null): unknown;
|
|
33
38
|
export {};
|
|
@@ -20,6 +20,22 @@ import { TOOL_ICON } from '../../../core/preview-registry.js';
|
|
|
20
20
|
function str(value) {
|
|
21
21
|
return typeof value === 'string' ? value : '';
|
|
22
22
|
}
|
|
23
|
+
/** How long a bash command must have been running before the transcript offers
|
|
24
|
+
* the backgrounding gesture under it. */
|
|
25
|
+
const BACKGROUND_HINT_AFTER_MS = 5_000;
|
|
26
|
+
/** The backgrounding affordance, under the running call it applies to, once the
|
|
27
|
+
* command has been running long enough that waiting it out is a real choice.
|
|
28
|
+
* Empty for a finished command, one short of the threshold, or a viewer that
|
|
29
|
+
* cannot invoke the handoff — the transcript is the user/assistant exchange,
|
|
30
|
+
* so this row exists only while it is actionable. */
|
|
31
|
+
function backgroundHintLines(theme, startedAt, hint, width) {
|
|
32
|
+
if (startedAt === undefined || Date.now() - startedAt < BACKGROUND_HINT_AFTER_MS)
|
|
33
|
+
return [];
|
|
34
|
+
const text = hint?.() ?? null;
|
|
35
|
+
if (text === null)
|
|
36
|
+
return [];
|
|
37
|
+
return [truncateToWidth(` ${theme.fg('muted', `⧗ ${text}`)}`, width, '…')];
|
|
38
|
+
}
|
|
23
39
|
/** Per-tool fold state owned by the attach viewer. Tool definitions capture the
|
|
24
40
|
* call component they render while folded, so the viewer never reaches into
|
|
25
41
|
* `ToolExecutionComponent`'s private renderer fields to recover it. */
|
|
@@ -236,16 +252,33 @@ class BashCallComponent {
|
|
|
236
252
|
folded = false;
|
|
237
253
|
timeout;
|
|
238
254
|
theme;
|
|
255
|
+
startedAt;
|
|
256
|
+
hint;
|
|
239
257
|
/** Nothing is cached across paints (render recomputes from the fields), so
|
|
240
258
|
* pi's cache-drop hook has nothing to do here. */
|
|
241
259
|
invalidate() { }
|
|
242
|
-
update(theme, command, purpose, expanded, folded, timeout) {
|
|
260
|
+
update(theme, command, purpose, expanded, folded, timeout, startedAt, hint) {
|
|
243
261
|
this.theme = theme;
|
|
244
262
|
this.command = command;
|
|
245
263
|
this.purpose = purpose;
|
|
246
264
|
this.expanded = expanded;
|
|
247
265
|
this.folded = folded;
|
|
248
266
|
this.timeout = timeout;
|
|
267
|
+
this.startedAt = startedAt;
|
|
268
|
+
this.hint = hint;
|
|
269
|
+
}
|
|
270
|
+
/** The backgrounding affordance, under the call it applies to, once this
|
|
271
|
+
* command has been running long enough that waiting it out is a real choice.
|
|
272
|
+
* Empty for a command that has finished, one that has not reached the
|
|
273
|
+
* threshold, or a viewer that cannot invoke the handoff — the transcript is
|
|
274
|
+
* the user/assistant exchange, so this row exists only while it is
|
|
275
|
+
* actionable. */
|
|
276
|
+
/** The backgrounding row for this call. Rendered by the tool component that
|
|
277
|
+
* OWNS this call, after the whole block, so it lands outside the highlighted
|
|
278
|
+
* band the block paints rather than inside it. */
|
|
279
|
+
backgroundHintLines(width) {
|
|
280
|
+
const theme = this.theme;
|
|
281
|
+
return theme === undefined ? [] : backgroundHintLines(theme, this.startedAt, this.hint, width);
|
|
249
282
|
}
|
|
250
283
|
render(width) {
|
|
251
284
|
const theme = this.theme;
|
|
@@ -286,8 +319,15 @@ class BashCallComponent {
|
|
|
286
319
|
return [truncateToWidth(`${icon} ${styleCommandLine(shown, theme)}${more}${suffix}`, width)];
|
|
287
320
|
}
|
|
288
321
|
}
|
|
322
|
+
/** The backgrounding row for a tool component's captured call — nothing unless
|
|
323
|
+
* that call is a bash command still running past the threshold. The owning tool
|
|
324
|
+
* component renders it after its whole block, which is what keeps the row
|
|
325
|
+
* outside the highlighted band. */
|
|
326
|
+
export function bashCallBackgroundHintLines(call, width) {
|
|
327
|
+
return call instanceof BashCallComponent ? call.backgroundHintLines(width) : [];
|
|
328
|
+
}
|
|
289
329
|
/** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
|
|
290
|
-
export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController()) {
|
|
330
|
+
export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController(), backgroundHint) {
|
|
291
331
|
const builtin = createBashToolDefinition(cwd);
|
|
292
332
|
return {
|
|
293
333
|
name: 'bash',
|
|
@@ -305,12 +345,16 @@ export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCall
|
|
|
305
345
|
state.endedAt = undefined;
|
|
306
346
|
}
|
|
307
347
|
const component = context.lastComponent instanceof BashCallComponent ? context.lastComponent : new BashCallComponent();
|
|
348
|
+
// Running = execution started and no result has stamped `endedAt`. The
|
|
349
|
+
// hint row lives on that fact, so it disappears the moment the command
|
|
350
|
+
// returns without anything having to clear it.
|
|
351
|
+
const running = state?.startedAt !== undefined && state.endedAt === undefined;
|
|
308
352
|
component.update(theme, str(a?.command),
|
|
309
353
|
// The purpose stands in for the command only while folded; it is normalized
|
|
310
354
|
// here because it is model-supplied text painted straight into the frame.
|
|
311
355
|
fold.folded ? normalizeBashJobPurpose(a?.purpose) ?? undefined : undefined,
|
|
312
356
|
// Folded wins over ctrl+o expansion: a folded bash is always its one-line form.
|
|
313
|
-
Boolean(context.expanded) && !fold.folded, fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined);
|
|
357
|
+
Boolean(context.expanded) && !fold.folded, fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined, running ? state?.startedAt : undefined, backgroundHint);
|
|
314
358
|
return fold.capture(component);
|
|
315
359
|
},
|
|
316
360
|
renderResult(result, options, theme, context) {
|
|
@@ -5,7 +5,7 @@ export type AttachActions = Record<AttachBindingId, () => void>;
|
|
|
5
5
|
export declare const ATTACH_VIEWPORT_BINDINGS: readonly ["crtr.attach.scroll.up", "crtr.attach.scroll.down", "crtr.attach.scroll.page-up", "crtr.attach.scroll.page-down", "crtr.attach.scroll.live", "crtr.attach.mouse.toggle", "crtr.attach.copy.message"];
|
|
6
6
|
export type AttachViewportBindingId = typeof ATTACH_VIEWPORT_BINDINGS[number];
|
|
7
7
|
export type AttachViewportActions = Record<AttachViewportBindingId, () => void>;
|
|
8
|
-
export { inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
8
|
+
export { backgroundBashHint, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
9
9
|
export declare function matchesAttachBinding(bindings: AttachBindings, id: BindingId, data: string): boolean;
|
|
10
10
|
export declare function dispatchAttachAction(id: AttachBindingId, actions: AttachActions): void;
|
|
11
11
|
export declare function dispatchAttachBinding(bindings: AttachBindings, data: string, actions: AttachActions): boolean;
|
|
@@ -13,7 +13,7 @@ export const ATTACH_VIEWPORT_BINDINGS = [
|
|
|
13
13
|
'crtr.attach.mouse.toggle',
|
|
14
14
|
'crtr.attach.copy.message',
|
|
15
15
|
];
|
|
16
|
-
export { inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
16
|
+
export { backgroundBashHint, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
17
17
|
export function matchesAttachBinding(bindings, id, data) {
|
|
18
18
|
// Match the normalized chunk, never the raw one: a terminal that sends Option
|
|
19
19
|
// as a meta prefix spells Alt+Shift+M in a form pi-tui rejects. Only matching
|
|
@@ -13,6 +13,9 @@ export interface ChromeRefreshHooks {
|
|
|
13
13
|
refreshIdentity: () => void;
|
|
14
14
|
renderFooter: () => void;
|
|
15
15
|
renderExtensionWidgets: () => void;
|
|
16
|
+
/** Re-measure the tool calls still in flight, so a silent long-running bash
|
|
17
|
+
* call still reaches its backgrounding hint with no per-call timer. */
|
|
18
|
+
refreshInFlightTools: () => void;
|
|
16
19
|
/** Re-poll this pane's own git context (owned by the editor chrome). */
|
|
17
20
|
pollGit: () => void;
|
|
18
21
|
}
|
|
@@ -62,9 +62,9 @@ export function createChromeRefresh(ctx, hooks) {
|
|
|
62
62
|
hooks.refreshIdentity();
|
|
63
63
|
hooks.renderExtensionWidgets();
|
|
64
64
|
hooks.pollGit();
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
65
|
+
// A long-running SILENT bash call emits no tool-execution update, so this
|
|
66
|
+
// cadence is what brings its backgrounding hint on screen.
|
|
67
|
+
hooks.refreshInFlightTools();
|
|
68
68
|
hooks.renderFooter();
|
|
69
69
|
void (async () => {
|
|
70
70
|
const root = await climbRootFromSource(nodeId);
|
|
@@ -38,7 +38,8 @@ export const CANVAS_SLASH_COMMANDS = [
|
|
|
38
38
|
{ name: 'graph', description: 'Toggle the canvas GRAPH view (your local subscription tree)' },
|
|
39
39
|
{ name: 'inbox', description: 'Toggle the human inbox — pending asks and reviews for you' },
|
|
40
40
|
{ name: 'promote', description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize' },
|
|
41
|
-
{ name: 'resume
|
|
41
|
+
{ name: 'resume', description: 'Open the canvas navigator and swap THIS pane onto the chosen node; parked residents wake only on submitted input' },
|
|
42
|
+
{ name: 'resume-session', description: 'Switch this node onto one of its saved pi sessions — picker, or /resume-session <session-path>' },
|
|
42
43
|
{ name: 'context', description: 'Browse this node + its subscriptions’ context dirs in a read-only nvim popup' },
|
|
43
44
|
{ name: 'plugins', description: 'Open the interactive plugin browser for this machine' },
|
|
44
45
|
{ name: 'node-metadata', description: 'Inspect this node — identity, lifecycle, runtime, graph, artifacts, paths, and agent surfaces' },
|
|
@@ -51,7 +52,11 @@ export const CANVAS_SLASH_COMMANDS = [
|
|
|
51
52
|
{ name: 'copy-transcript', description: 'Copy every copyable transcript block' },
|
|
52
53
|
{ name: 'fold-tools', description: 'View setting — fold every FINISHED tool call to its single call line (Alt+C → z → f)' },
|
|
53
54
|
];
|
|
54
|
-
|
|
55
|
+
/** Prior names still dispatched natively but kept OUT of autocomplete, so
|
|
56
|
+
* muscle memory keeps working while the palette shows one canonical entry.
|
|
57
|
+
* Normalized to the canonical name before any gate or dispatch runs. */
|
|
58
|
+
const CANVAS_ALIASES = new Map([['resume-node', 'resume']]);
|
|
59
|
+
const CANVAS_NAMES = new Set([...CANVAS_SLASH_COMMANDS.map((c) => c.name), ...CANVAS_ALIASES.keys()]);
|
|
55
60
|
/** tmux named colours accepted by `/color` (plus `colourN`/`#rrggbb`, validated
|
|
56
61
|
* by pattern). Mirrors tmux's colour-name table so a typo is rejected with the
|
|
57
62
|
* options rather than silently ignored by tmux. */
|
|
@@ -119,7 +124,8 @@ export function dispatchSlashCommand(text, ctx) {
|
|
|
119
124
|
const command = parseSlashCommand(text);
|
|
120
125
|
if (command === undefined)
|
|
121
126
|
return false;
|
|
122
|
-
const {
|
|
127
|
+
const { arg } = command;
|
|
128
|
+
const name = CANVAS_ALIASES.get(command.name) ?? command.name;
|
|
123
129
|
if (SCOPED_OUT.has(name)) {
|
|
124
130
|
ctx.notify(`/${name} is not supported in attach (Phase 4)`);
|
|
125
131
|
return true;
|
|
@@ -189,11 +195,6 @@ export function dispatchSlashCommand(text, ctx) {
|
|
|
189
195
|
return openOrHint(ctx.openModelPicker, ctx, 'model', '/model <provider/id>');
|
|
190
196
|
ctx.send({ type: 'set_model', model: arg });
|
|
191
197
|
return true;
|
|
192
|
-
case 'resume':
|
|
193
|
-
if (!arg)
|
|
194
|
-
return openOrHint(ctx.openSessionPicker, ctx, 'session', '/resume <session-path>');
|
|
195
|
-
ctx.send({ type: 'switch_session', path: arg });
|
|
196
|
-
return true;
|
|
197
198
|
case 'fork':
|
|
198
199
|
if (!arg)
|
|
199
200
|
return openOrHint(ctx.openForkPicker, ctx, 'fork point', '/fork <entryId>');
|
|
@@ -277,12 +278,24 @@ export function dispatchSlashCommand(text, ctx) {
|
|
|
277
278
|
* omitted rather than offered-then-blocked, so autocomplete never advertises a
|
|
278
279
|
* command `dispatchCanvasCommand` would refuse to run. */
|
|
279
280
|
function withCanvasCommands(commands, capabilities) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
const operation = viewerOperationForSlash(command.name, false, true);
|
|
281
|
+
const permitted = (name) => {
|
|
282
|
+
const operation = viewerOperationForSlash(name, false, true);
|
|
283
283
|
return operation === undefined || capabilities.permits(operation);
|
|
284
|
+
};
|
|
285
|
+
const offerable = CANVAS_SLASH_COMMANDS.filter((command) => permitted(command.name));
|
|
286
|
+
// A canvas command that REPURPOSES a builtin name (`/resume`) keeps that name's
|
|
287
|
+
// slot in the incoming list but carries the canvas description and the canvas
|
|
288
|
+
// capability gate — so autocomplete never advertises pi's meaning for a command
|
|
289
|
+
// this viewer redefines, and never offers one it would refuse to run.
|
|
290
|
+
const canvasByName = new Map(CANVAS_SLASH_COMMANDS.map((command) => [command.name, command]));
|
|
291
|
+
const merged = commands.flatMap((command) => {
|
|
292
|
+
const canvas = canvasByName.get(command.name);
|
|
293
|
+
if (canvas === undefined)
|
|
294
|
+
return [command];
|
|
295
|
+
return permitted(command.name) ? [canvas] : [];
|
|
284
296
|
});
|
|
285
|
-
|
|
297
|
+
const seen = new Set(commands.map((c) => c.name));
|
|
298
|
+
return [...merged, ...offerable.filter((command) => !seen.has(command.name))];
|
|
286
299
|
}
|
|
287
300
|
/** Build slash-command autocomplete entries from the merged command list (the
|
|
288
301
|
* broker's `get_commands` result, which T7 may inject) — defaults to the
|
|
@@ -326,10 +339,19 @@ function dispatchCanvasCommand(name, arg, ctx) {
|
|
|
326
339
|
case 'promote':
|
|
327
340
|
promoteNode(arg, ctx);
|
|
328
341
|
return true;
|
|
329
|
-
case 'resume
|
|
330
|
-
// Open the full-screen canvas navigator as a popup; on Enter it
|
|
331
|
-
//
|
|
342
|
+
case 'resume':
|
|
343
|
+
// Open the full-screen canvas navigator as a popup; on Enter it swaps THIS
|
|
344
|
+
// pane onto the chosen node via `crtr surface node focus --in-place`.
|
|
345
|
+
if (arg)
|
|
346
|
+
return usage(ctx, '/resume — no argument; use /resume-session <path> to switch pi sessions');
|
|
332
347
|
return resumeNode(ctx);
|
|
348
|
+
case 'resume-session':
|
|
349
|
+
// The pi session picker: rebind THIS node onto a different saved session
|
|
350
|
+
// file (recovering a conversation the node cycled away from).
|
|
351
|
+
if (!arg)
|
|
352
|
+
return openOrHint(ctx.openSessionPicker, ctx, 'session', '/resume-session <session-path>');
|
|
353
|
+
ctx.send({ type: 'switch_session', path: arg });
|
|
354
|
+
return true;
|
|
333
355
|
case 'mcp':
|
|
334
356
|
// MCP server status panel — viewer-local reads of the adapter's on-disk
|
|
335
357
|
// config sources/cache (see overlays/mcp.ts); the actions (connect /
|
|
@@ -556,13 +578,14 @@ function colorNode(arg, ctx) {
|
|
|
556
578
|
ctx.notify(clear ? 'Node color cleared' : `Node colored ${value}${inTmux ? '' : ' (chip only — no tmux pane)'}`);
|
|
557
579
|
return true;
|
|
558
580
|
}
|
|
559
|
-
/** `/resume
|
|
560
|
-
*
|
|
561
|
-
*
|
|
581
|
+
/** `/resume`. Opens `crtr canvas browse` as a tmux popup, initially scoped to
|
|
582
|
+
* this node's profile (with all dirs visible); on Enter the navigator respawns
|
|
583
|
+
* THIS pane onto the chosen node (`crtr surface node focus --pane --in-place`),
|
|
584
|
+
* so resuming replaces the viewer you invoked it from. */
|
|
562
585
|
function resumeNode(ctx) {
|
|
563
586
|
const origPane = process.env['TMUX_PANE'];
|
|
564
587
|
if (process.env['TMUX'] === undefined || origPane === undefined || origPane === '') {
|
|
565
|
-
ctx.notify('/resume
|
|
588
|
+
ctx.notify('/resume needs tmux');
|
|
566
589
|
return true;
|
|
567
590
|
}
|
|
568
591
|
const cwd = shellQuote(ctx.cwd ?? process.cwd());
|