@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.
Files changed (37) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +11 -3
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +11 -3
  3. package/dist/clients/attach/chrome/status-line.js +1 -22
  4. package/dist/clients/attach/input/capabilities.js +2 -1
  5. package/dist/clients/attach/render/chat-view.d.ts +11 -0
  6. package/dist/clients/attach/render/chat-view.js +23 -4
  7. package/dist/clients/attach/render/tool-calls.d.ts +6 -1
  8. package/dist/clients/attach/render/tool-calls.js +47 -3
  9. package/dist/clients/attach/session/bindings.d.ts +1 -1
  10. package/dist/clients/attach/session/bindings.js +1 -1
  11. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  12. package/dist/clients/attach/session/chrome-refresh.js +3 -3
  13. package/dist/clients/attach/slash/dispatch.js +42 -19
  14. package/dist/clients/attach/viewer.js +564 -564
  15. package/dist/commands/canvas-browse.js +2 -2
  16. package/dist/commands/node/bash.js +5 -8
  17. package/dist/core/__tests__/helpers/fleet.d.ts +1 -1
  18. package/dist/core/__tests__/helpers/fleet.js +5 -0
  19. package/dist/core/__tests__/helpers/harness.js +5 -1
  20. package/dist/core/canvas/browse/app.d.ts +1 -1
  21. package/dist/core/canvas/browse/app.js +32 -7
  22. package/dist/core/canvas/browse/render.d.ts +17 -2
  23. package/dist/core/canvas/browse/render.js +342 -242
  24. package/dist/core/canvas/render-source.d.ts +26 -0
  25. package/dist/core/canvas/render-source.js +73 -0
  26. package/dist/core/keybindings/catalog.d.ts +2 -2
  27. package/dist/core/keybindings/catalog.js +1 -0
  28. package/dist/core/runtime/busy.d.ts +13 -0
  29. package/dist/core/runtime/busy.js +43 -1
  30. package/dist/core/runtime/revive.js +2 -0
  31. package/dist/core/runtime/tmux-bindings.js +1 -1
  32. package/dist/daemon/fleet.d.ts +3 -0
  33. package/dist/daemon/fleet.js +35 -5
  34. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +14 -0
  35. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +18 -0
  36. package/package.json +1 -1
  37. 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
- // also trips this — harmlessly: the respawned broker fails the same way and the
106
- // daemon's respawn backoff paces the loop.
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
- // also trips this — harmlessly: the respawned broker fails the same way and the
201
- // daemon's respawn backoff paces the loop.
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 { backgroundBashHint, formatBinding, inboxOpenHint } from '../../../core/keybindings/index.js';
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-node': return 'canvas-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
- if (!this.minimized)
229
- return super.render(width);
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
- // R4: re-evaluate the bash-hint threshold on the existing 5s cadence, so a
66
- // long-running SILENT bash call (no tool-execution update in between) still
67
- // surfaces the hint without a new per-call timer.
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-node', description: 'Open the canvas navigator and focus the chosen node; parked residents wake only on submitted input' },
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
- const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
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 { name, arg } = command;
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 seen = new Set(commands.map((c) => c.name));
281
- const offerable = CANVAS_SLASH_COMMANDS.filter((command) => {
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
- return [...commands, ...offerable.filter((command) => !seen.has(command.name))];
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-node':
330
- // Open the full-screen canvas navigator as a popup; on Enter it focuses the
331
- // chosen node back into THIS pane via `crtr surface node focus`.
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-node`. Opens `crtr canvas browse` as a tmux popup, initially scoped
560
- * to this node's profile (with all dirs visible); on Enter it focuses the chosen
561
- * node back into this pane via `crtr surface node focus --pane`. */
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-node needs tmux');
588
+ ctx.notify('/resume needs tmux');
566
589
  return true;
567
590
  }
568
591
  const cwd = shellQuote(ctx.cwd ?? process.cwd());