@north-light/crouter 0.3.278 → 0.3.280

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 (36) hide show
  1. package/dist/api/dto/model-config.d.ts +25 -0
  2. package/dist/api/dto/model-config.js +1 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +11 -3
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +11 -3
  5. package/dist/clients/attach/chrome/status-line.js +1 -22
  6. package/dist/clients/attach/render/chat-view.d.ts +11 -0
  7. package/dist/clients/attach/render/chat-view.js +23 -4
  8. package/dist/clients/attach/render/tool-calls.d.ts +6 -1
  9. package/dist/clients/attach/render/tool-calls.js +47 -3
  10. package/dist/clients/attach/session/bindings.d.ts +1 -1
  11. package/dist/clients/attach/session/bindings.js +1 -1
  12. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  13. package/dist/clients/attach/session/chrome-refresh.js +3 -3
  14. package/dist/clients/attach/viewer.js +557 -557
  15. package/dist/commands/node/bash.js +5 -8
  16. package/dist/core/__tests__/helpers/fleet.d.ts +1 -1
  17. package/dist/core/__tests__/helpers/fleet.js +5 -0
  18. package/dist/core/__tests__/helpers/harness.js +5 -1
  19. package/dist/core/model-config.d.ts +20 -0
  20. package/dist/core/model-config.js +107 -0
  21. package/dist/core/model-routes.d.ts +5 -0
  22. package/dist/core/model-routes.js +5 -5
  23. package/dist/core/runtime/busy.d.ts +13 -0
  24. package/dist/core/runtime/busy.js +43 -1
  25. package/dist/core/runtime/revive.js +2 -0
  26. package/dist/daemon/api/__tests__/model-config.test.d.ts +1 -0
  27. package/dist/daemon/api/__tests__/model-config.test.js +73 -0
  28. package/dist/daemon/api/handlers/model-config.d.ts +2 -0
  29. package/dist/daemon/api/handlers/model-config.js +44 -0
  30. package/dist/daemon/api/server.js +2 -0
  31. package/dist/daemon/fleet.d.ts +3 -0
  32. package/dist/daemon/fleet.js +35 -5
  33. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +14 -0
  34. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +18 -0
  35. package/package.json +1 -1
  36. package/runtime.lock.json +2 -2
@@ -0,0 +1,25 @@
1
+ export type ModelStrengthDTO = 'ultra' | 'strong' | 'medium' | 'light';
2
+ export interface ModelRouteConfigDTO {
3
+ family: string;
4
+ credentialSource: string;
5
+ providerId: string;
6
+ models: Record<ModelStrengthDTO, string>;
7
+ }
8
+ export interface ModelRoutingDTO {
9
+ credentialSourceOrder?: string[];
10
+ familyOrder?: string[];
11
+ strengthFallback?: ModelStrengthDTO[];
12
+ }
13
+ export interface PutModelConfigRequest {
14
+ config_version: string;
15
+ modelRoutes: Record<string, ModelRouteConfigDTO>;
16
+ modelRouting: ModelRoutingDTO;
17
+ kinds: Record<string, {
18
+ model: string;
19
+ orchestratorModel?: string;
20
+ }>;
21
+ }
22
+ export interface PutModelConfigResponse {
23
+ config_version: string;
24
+ delivered: true;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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`));
@@ -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);