@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
@@ -23,14 +23,14 @@ export const browseLeaf = defineLeaf({
23
23
  name: 'return-pane',
24
24
  type: 'string',
25
25
  required: false,
26
- constraint: 'tmux pane id to focus the chosen node INTO (set by the /resume-node popup so the node lands back in your pi pane). Default: this pane.',
26
+ constraint: 'tmux pane id the chosen node REPLACES on Enter — that pane is respawned onto its viewer (set by the /resume popup, so resuming swaps the pane you invoked it from). Default: focus into this pane without replacing it.',
27
27
  },
28
28
  {
29
29
  kind: 'flag',
30
30
  name: 'cwd',
31
31
  type: 'string',
32
32
  required: false,
33
- constraint: 'directory used when you toggle the optional cwd filter (c). The navigator opens with All dirs; /resume-node passes the launching node\'s cwd. Default: the invocation cwd.',
33
+ constraint: 'directory used when you toggle the optional cwd filter (c). The navigator opens with All dirs; /resume passes the launching node\'s cwd. Default: the invocation cwd.',
34
34
  },
35
35
  {
36
36
  kind: 'flag',
@@ -2,7 +2,6 @@
2
2
  import { envNodeId } from '../../shared/env.js';
3
3
  import { defineBranch, defineLeaf } from '../../core/command.js';
4
4
  import { InputError } from '../../core/io.js';
5
- import { displayMessage } from '../../core/runtime/tmux-chrome.js';
6
5
  import { contextDir } from '../../core/canvas/paths.js';
7
6
  import { existsSync } from 'node:fs';
8
7
  import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobCommandLine, bashJobPaths, clearJobDeadline, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog, writeJobDeadlineMs } from '../../core/bash-jobs.js';
@@ -41,19 +40,17 @@ const nodeBashBackground = defineLeaf({
41
40
  effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an urgent inbox message when it exits, which steers the node mid-turn.'],
42
41
  },
43
42
  run: async (input) => {
44
- const { id, pane } = resolveBashTarget(input, 'no node found for the bash handoff');
43
+ const { id } = resolveBashTarget(input, 'no node found for the bash handoff');
45
44
  if ((await getNodeOrNull(id)) === null) {
46
45
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
47
46
  }
48
47
  const jobs = backgroundRunningBashJobs(contextDir(id));
49
48
  const now = Date.now();
50
49
  const elapsed = jobs.map((job) => ({ job_id: job.jobId, elapsed_ms: now - (jobStartedAtMs(job.jobId) ?? now) }));
51
- const toast = jobs.length === 0
52
- ? 'crtr: no running bash command'
53
- : jobs.length === 1
54
- ? `crtr: bash handed off · ${formatBashElapsed(elapsed[0].elapsed_ms)}`
55
- : `crtr: bash handed off (${jobs.length})`;
56
- displayMessage(toast, pane);
50
+ // No tmux toast. A tmux status-line message commandeers the whole client's
51
+ // status bar for seconds over an action the user just took deliberately;
52
+ // the viewer reports its own handoff in its own status line, which is where
53
+ // someone watching that pane is already looking.
57
54
  return { node_id: id, jobs: elapsed };
58
55
  },
59
56
  render: (result) => {
@@ -9,4 +9,4 @@ export interface TestFleet {
9
9
  * process's fleet. Call in `before`/`beforeEach`; `bindFleet` throws on a
10
10
  * conflicting rebind, so the previous binding is reset first (same idiom as
11
11
  * resetEventSourceForTests). Pass `now` to inject the throttle clock. */
12
- export declare function bindTestFleet(opts?: Partial<Pick<DaemonFleetOpts, 'epoch' | 'now'>>): TestFleet;
12
+ export declare function bindTestFleet(opts?: Partial<Pick<DaemonFleetOpts, 'epoch' | 'now' | 'probeOnline'>>): TestFleet;
@@ -30,6 +30,11 @@ export function bindTestFleet(opts = {}) {
30
30
  });
31
31
  },
32
32
  ...(opts.now !== undefined ? { now: opts.now } : {}),
33
+ // Respawn policy consults network reachability — an offline box parks a
34
+ // node rather than spending its respawn budget. Default it online so tests
35
+ // stay deterministic off the network; pass `probeOnline` to drive the
36
+ // outage branch.
37
+ probeOnline: opts.probeOnline ?? (async () => true),
33
38
  });
34
39
  bindFleet(fleet);
35
40
  return {
@@ -252,7 +252,11 @@ export async function createHarness(opts = {}) {
252
252
  await tail;
253
253
  } while (tail !== laneTail);
254
254
  }
255
- const fleet = new DaemonFleet({ epoch: 'harness-epoch', enqueue: enqueueLaneJob });
255
+ // Respawn policy probes network reachability (an offline box parks a node
256
+ // instead of spending its respawn budget). Reachability is an environment
257
+ // condition, not runtime behavior under test — pin it online so a sandboxed
258
+ // or offline runner exercises the same recovery path a connected one does.
259
+ const fleet = new DaemonFleet({ epoch: 'harness-epoch', enqueue: enqueueLaneJob, probeOnline: async () => true });
256
260
  bindFleet(fleet);
257
261
  // Neutralize ensureDaemon (spawn.ts calls it on every `node new`): write a
258
262
  // fake-live-daemon pidfile pointing at OUR (always-alive) pid, so
@@ -1,6 +1,6 @@
1
1
  import type { CanvasSource } from '../source.js';
2
2
  import { type BindingId, type BindingResolution, type RawTerminalInput } from '../../keybindings/index.js';
3
- declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.pin", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
3
+ declare const BROWSE_NAV_ACTIONS: readonly ["crtr.browse.quit", "crtr.browse.down", "crtr.browse.up", "crtr.browse.first", "crtr.browse.last", "crtr.browse.expand-or-child", "crtr.browse.collapse-or-parent", "crtr.browse.toggle-fold", "crtr.browse.tab-next", "crtr.browse.tab-previous", "crtr.browse.tab.1", "crtr.browse.tab.2", "crtr.browse.tab.3", "crtr.browse.tab.4", "crtr.browse.tab.5", "crtr.browse.close-subtree", "crtr.browse.sort", "crtr.browse.scope", "crtr.browse.profile", "crtr.browse.residents", "crtr.browse.preview", "crtr.browse.report", "crtr.browse.pin", "crtr.browse.search", "crtr.browse.revive-now", "crtr.browse.focus", "crtr.browse.focus-background", "crtr.browse.yank-id"];
4
4
  declare const BROWSE_SEARCH_ACTIONS: readonly ["crtr.browse.search.cancel", "crtr.browse.search.commit", "crtr.browse.search.delete", "crtr.browse.search.quit"];
5
5
  export declare function resolveBrowseNavAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_NAV_ACTIONS)[number] | null;
6
6
  export declare function resolveBrowseSearchAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof BROWSE_SEARCH_ACTIONS)[number] | null;
@@ -10,7 +10,7 @@
10
10
  import { envNodeId } from '../../../shared/env.js';
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { resolve } from 'node:path';
13
- import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render-source.js';
13
+ import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, loadTranscriptFromSource, renderForestFromSource } from '../render-source.js';
14
14
  import { fullName } from '../labels.js';
15
15
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
16
16
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
@@ -22,7 +22,7 @@ const BROWSE_NAV_ACTIONS = [
22
22
  'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
23
23
  'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
24
24
  'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview',
25
- 'crtr.browse.pin', 'crtr.browse.search',
25
+ 'crtr.browse.report', 'crtr.browse.pin', 'crtr.browse.search',
26
26
  'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
27
27
  ];
28
28
  const BROWSE_SEARCH_ACTIONS = [
@@ -120,7 +120,8 @@ export async function runBrowse(opts) {
120
120
  cwdScope: null, // default: All dirs
121
121
  profileScope: launchProfileId,
122
122
  sort: 'attention', // default: attention ordering (attached/streaming/live first)
123
- preview: false, // default OFF (decision) — `v` opens it for the hovered row
123
+ preview: true, // default ON — the hovered node's conversation is the point
124
+ showReport: false,
124
125
  residentsOnly: true, // default ON: hide one-shot workers (decision)
125
126
  pendingClose: null,
126
127
  flash: null,
@@ -168,12 +169,24 @@ export async function runBrowse(opts) {
168
169
  /* best-effort — the SIGTERM is what matters; the daemon owns the resume */
169
170
  }
170
171
  };
171
- /** Open the chosen node — the ONLY sanctioned path (reviveNode via surface node focus). */
172
+ /** Open the chosen node — the ONLY sanctioned path (reviveNode via surface node focus).
173
+ * With a return pane (the `/resume` popup), the chosen node REPLACES that pane:
174
+ * `--in-place` respawns it onto the node, so resuming swaps the viewer you came
175
+ * from instead of splitting another one beside it. The respawn kills that pane's
176
+ * process tree, so it is dispatched through `tmux run-shell -b` — the tmux server
177
+ * context — and never as a child of this popup, which the same teardown can take
178
+ * with it mid-call. Without a return pane, the caller's own pane hosts the viewer
179
+ * as before. */
172
180
  const selectAndFocus = (id) => {
173
181
  cleanup();
174
- const args = ['surface', 'node', 'focus', id, ...(opts.returnPane !== undefined && opts.returnPane !== '' ? ['--pane', opts.returnPane] : [])];
182
+ const returnPane = opts.returnPane !== undefined && opts.returnPane !== '' ? opts.returnPane : undefined;
175
183
  try {
176
- execFileSync('crtr', args, { stdio: 'inherit' });
184
+ if (returnPane !== undefined) {
185
+ execFileSync('tmux', ['run-shell', '-b', `crtr surface node focus ${id} --pane ${returnPane} --in-place >/dev/null 2>&1`], { stdio: 'ignore' });
186
+ }
187
+ else {
188
+ execFileSync('crtr', ['surface', 'node', 'focus', id], { stdio: 'inherit' });
189
+ }
177
190
  }
178
191
  catch {
179
192
  // `surface node focus` swaps panes out from under us; a sync call can be
@@ -244,13 +257,20 @@ export async function runBrowse(opts) {
244
257
  const cur = curRow();
245
258
  if (cur !== undefined) {
246
259
  const r = rowOf(cur.id);
247
- if (r !== undefined)
260
+ if (r !== undefined) {
248
261
  await loadPreviewFromSource(source, r);
262
+ // The chat transcript is the side panel's body, so it is read for the
263
+ // SELECTED row only — never on the background warm pass, where one ordered
264
+ // transcript per node would hold the whole canvas's conversations in memory.
265
+ if (previewLayout(size.cols, previewOn).mode === 'side')
266
+ await loadTranscriptFromSource(source, r);
267
+ }
249
268
  }
250
269
  const frame = renderFrame({
251
270
  tree, visible, tab: state.tab, cursor: state.cursor, scrollOffset: state.scrollOffset,
252
271
  query: state.query, search: state.search, totalNodes,
253
272
  cwdScope: state.cwdScope, profileScope: state.profileScope, sort: state.sort, preview: state.preview,
273
+ showReport: state.showReport,
254
274
  residentsOnly: state.residentsOnly, pendingClose: state.pendingClose, flash: state.flash,
255
275
  pinned: state.pinned, help: state.help,
256
276
  rename: state.rename, renameBuf: state.renameBuf,
@@ -704,6 +724,11 @@ export async function runBrowse(opts) {
704
724
  scheduleFlush();
705
725
  return;
706
726
  }
727
+ if (action === 'crtr.browse.report') {
728
+ state.showReport = !state.showReport;
729
+ scheduleFlush();
730
+ return;
731
+ }
707
732
  // Help overlay.
708
733
  if (input === '?') {
709
734
  state.help = true;
@@ -1,5 +1,6 @@
1
+ import type { TranscriptTurn } from '../render-source.js';
1
2
  import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
2
- import { detectColorCaps, type ColorCaps } from '../../tui/draw.js';
3
+ import { detectColorCaps, type Span, type ColorCaps } from '../../tui/draw.js';
3
4
  export { detectColorCaps };
4
5
  export type { ColorCaps };
5
6
  export declare const RECAP_LINES = 2;
@@ -33,8 +34,12 @@ export interface RenderState {
33
34
  profileScope: string | undefined;
34
35
  /** Active ordering — status line + (flat modes) row presentation. */
35
36
  sort: SortMode;
36
- /** Whether the bottom preview panel is drawn. */
37
+ /** Whether the preview panel is drawn. */
37
38
  preview: boolean;
39
+ /** Side panel shows the node's newest report instead of the goal/chat/state
40
+ * briefing. Ignored while a search query is live (the transcript owns the panel)
41
+ * and for a node that has pushed nothing. */
42
+ showReport?: boolean;
38
43
  /** Lifecycle filter: when true, `terminal` (one-shot worker) nodes are hidden
39
44
  * — surfaced as a status-line cue. */
40
45
  residentsOnly: boolean;
@@ -72,6 +77,16 @@ export declare function previewAllocation(hasRecap: boolean, hasReport: boolean)
72
77
  reply: number;
73
78
  report: number;
74
79
  };
80
+ /** Re-style every case-insensitive occurrence of `query` inside already-rendered
81
+ * spans, splitting runs at the match edges. Working on the RENDERED text is what
82
+ * lets a transcript keep its markdown and still highlight: the markup is already
83
+ * gone, so no column can drift out from under a highlight. */
84
+ export declare function highlightSpans(spans: Span[], query: string): {
85
+ spans: Span[];
86
+ hits: number;
87
+ };
88
+ /** How many turns contain the query — the count the section rule advertises. */
89
+ export declare function transcriptHitTurns(turns: readonly TranscriptTurn[], query: string): number;
75
90
  /**
76
91
  * Render the whole frame. Returns a single string that, written as-is, repaints
77
92
  * the screen in place. `caps` gates all hue (defaults to a no-color frame so