@crouton-kit/crouter 0.3.82 → 0.3.83

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 (80) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -3
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +16 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +1 -2
  7. package/dist/clients/attach/attach-cmd.js +685 -685
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +51 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +13 -17
  22. package/dist/commands/node.js +4 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +98 -0
  26. package/dist/commands/sys/setup-core.d.ts +22 -0
  27. package/dist/commands/sys/setup-core.js +62 -0
  28. package/dist/commands/sys/setup.js +8 -1
  29. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  30. package/dist/core/__tests__/human-deliver.test.js +332 -0
  31. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  32. package/dist/core/__tests__/pid-identity-match.test.js +83 -0
  33. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  34. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  35. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  36. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  37. package/dist/core/canvas/attention.d.ts +16 -14
  38. package/dist/core/canvas/attention.js +34 -29
  39. package/dist/core/canvas/boot.d.ts +1 -0
  40. package/dist/core/canvas/boot.js +1 -1
  41. package/dist/core/canvas/browse/app.js +3 -3
  42. package/dist/core/canvas/pid.d.ts +43 -17
  43. package/dist/core/canvas/pid.js +104 -35
  44. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  45. package/dist/core/canvas/remote-canvas-source.js +4 -2
  46. package/dist/core/canvas/render.d.ts +5 -5
  47. package/dist/core/canvas/render.js +14 -14
  48. package/dist/core/canvas/source.d.ts +2 -2
  49. package/dist/core/canvas/source.js +3 -3
  50. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  51. package/dist/core/keybindings/catalog.d.ts +2 -2
  52. package/dist/core/keybindings/catalog.js +3 -2
  53. package/dist/core/preview-registry.js +22 -29
  54. package/dist/core/profiles/select.js +4 -4
  55. package/dist/core/runtime/pi-vendored.js +15 -1
  56. package/dist/core/runtime/placement.d.ts +1 -10
  57. package/dist/core/runtime/placement.js +43 -26
  58. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  59. package/dist/core/runtime/session-list-cache.js +92 -24
  60. package/dist/core/runtime/spawn.js +9 -0
  61. package/dist/core/spawn.d.ts +0 -59
  62. package/dist/core/spawn.js +5 -163
  63. package/dist/daemon/crtrd.js +7 -6
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.js +1 -1
  66. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  67. package/dist/web-client/index.html +1 -1
  68. package/dist/web-client/sw.js +1 -1
  69. package/package.json +2 -2
  70. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  73. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  74. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  75. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  76. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  77. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  78. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  79. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  80. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../../core/command.js';
2
+ export declare function registerAttach(): BranchDef;
@@ -0,0 +1,93 @@
1
+ // attach-def.ts — the LIGHT `crtr surface attach` command definition.
2
+ //
3
+ // This module holds only the command metadata (help schema, flags) and the
4
+ // thin `run` entry. It is deliberately kept OFF the heavy pi-coding-agent module
5
+ // graph so that registering the `surface` subtree — for `surface view`,
6
+ // `surface web`, `surface workspace`, `surface tmux-spread`, and every
7
+ // `surface -h` render — costs a cheap command-framework load, not the ~363ms
8
+ // ESM load of the esbuild-bundled attach viewer (`attach-cmd.ts`, which pulls
9
+ // pi-coding-agent's babel/highlight.js). The viewer runtime is dynamically
10
+ // imported inside `run`, so the bundle loads ONLY when a viewer actually opens.
11
+ import { defineBranch, defineLeaf } from '../../core/command.js';
12
+ const attachToLeaf = defineLeaf({
13
+ name: 'to',
14
+ description: 'attach an interactive terminal viewer to a node\'s running broker',
15
+ whenToUse: 'you want to WATCH or DRIVE a headless node live in this pane — it connects over the node\'s unix socket to the broker that is already hosting the engine and renders the same chat stream, letting you type prompts (as the controller) or follow read-only (as an observer). It does NOT start the engine: the node must already have a running headless broker (focus or revive it first). One controller drives; extra viewers are read-only. The configured detach shortcuts leave the engine running',
16
+ help: {
17
+ name: 'attach to',
18
+ summary: 'attach a terminal viewer to a headless node\'s running broker (controller by default, --observer for read-only); configured detach shortcuts leave the engine running',
19
+ params: [
20
+ {
21
+ kind: 'positional',
22
+ name: 'node',
23
+ required: true,
24
+ constraint: 'Node id to attach to. Must already have a running headless broker.',
25
+ },
26
+ {
27
+ kind: 'flag',
28
+ name: 'observer',
29
+ type: 'bool',
30
+ required: false,
31
+ default: false,
32
+ constraint: 'Attach READ-ONLY: never claim control even if it is free. Default: drive (claim control if available, else fall back to read-only).',
33
+ },
34
+ {
35
+ kind: 'flag',
36
+ name: 'canvas',
37
+ type: 'string',
38
+ required: false,
39
+ constraint: 'Attach against a remote canvas target configured via `crtr canvas config add` instead of the local canvas — connects over a relayed WebSocket to the target\'s preview endpoint using the relay token from the 0600 secrets store. Omit for local (default): connects to the node\'s local unix socket, which must already exist in THIS machine\'s canvas.db. Does NOT honor `crtr canvas use` — this flag is the only way to attach remotely.',
40
+ },
41
+ ],
42
+ output: [
43
+ {
44
+ name: 'note',
45
+ type: 'string',
46
+ required: false,
47
+ constraint: 'Only set on the non-TTY/piped path (a static notice); the interactive path returns nothing.',
48
+ },
49
+ ],
50
+ outputKind: 'object',
51
+ effects: [
52
+ 'Takes over the current pane in raw mode and renders the node\'s live engine stream until you use a configured detach shortcut or the broker exits.',
53
+ 'As controller: sends prompts/steers/dialog answers to the engine over the socket. As observer: read-only.',
54
+ 'NEVER spawns pi and NEVER writes the session — it holds only a socket to the existing broker.',
55
+ 'Outside a TTY (piped): prints a short notice and exits 0 — attach is an interactive program, not a pipe stage.',
56
+ ],
57
+ },
58
+ run: async (input) => {
59
+ const nodeId = input['node'];
60
+ const observer = input['observer'] ?? false;
61
+ const canvasName = input['canvas'];
62
+ // attach IS the in-pane interactive program — it only needs a TTY (crtr is
63
+ // tmux-only, but the pane itself is the surface). Non-TTY (piped) → static
64
+ // notice + exit, never a non-tmux interactive fallback. Guarded BEFORE the
65
+ // heavy import so a piped invocation never pays the viewer's load cost.
66
+ if (!process.stdout.isTTY) {
67
+ return {
68
+ note: `crtr surface attach is an interactive terminal viewer — run it in a tmux pane (a TTY), not a pipe. Node: ${nodeId}`,
69
+ };
70
+ }
71
+ // The viewer runtime (the pi-coding-agent-heavy bundle) loads only here.
72
+ const { runAttach } = await import('./attach-cmd.js');
73
+ await runAttach(nodeId, observer, canvasName);
74
+ return;
75
+ },
76
+ render: (result) => (result['note'] !== undefined ? String(result['note']) : ''),
77
+ });
78
+ export function registerAttach() {
79
+ return defineBranch({
80
+ name: 'attach',
81
+ rootEntry: {
82
+ concept: 'a terminal viewer for a headless node — connect over its socket and drive (or watch) the live engine',
83
+ desc: 'attach a terminal viewer to a headless node',
84
+ useWhen: 'you want to open a headless node live in this pane — watch its engine stream and drive it by typing, without the node ever running pi in your window. The node must already have a running headless broker; attach connects to it over a unix socket and never starts the engine itself. Configured detach shortcuts leave the engine running.',
85
+ },
86
+ help: {
87
+ name: 'attach',
88
+ summary: 'attach a terminal viewer to a headless node\'s running broker',
89
+ model: 'The branch opens a viewer in the current pane. A TTY is required; piped invocation prints a notice and exits. By default it claims control and drives the engine; --observer follows read-only. One controller plus any number of observers may watch a node. Configured detach shortcuts detach cleanly and the engine runs on. If the broker exits, the viewer holds the pane and auto-reconnects — through a yield/refresh relaunch, and across dormancy until the node wakes again — reporting "broker gone" and exiting only when the node is terminal (done/dead/canceled) or deleted. attach NEVER spawns pi or writes the session — it holds only a socket to the broker, which must already be running. --canvas <name> is the escape hatch for a node hosted on a different machine\'s canvas — see the leaf\'s own -h.',
90
+ },
91
+ children: [attachToLeaf],
92
+ });
93
+ }
@@ -3,10 +3,17 @@ import type { AttachPalette } from './config-load.js';
3
3
  export interface CanvasPanelLines {
4
4
  /** Above-editor manager line(s) — empty for a root node (no manager). */
5
5
  managers: string[];
6
- /** Below-editor report row(s) + self's trailing ⚑ asks line — empty when none. */
6
+ /** Below-editor report row(s) + self's trailing ⚑ pending-ticket line — empty when none. */
7
7
  reports: string[];
8
+ /** Node ids backing the manager row(s), in render order — the roster's UP leg
9
+ * (toward main). Drives the attach viewer's inline scroll+swap selection. */
10
+ managerIds: string[];
11
+ /** Node ids backing the report row(s), in render order — the roster's DOWN leg
12
+ * (the live subagents). Excludes the trailing ⚑ self-asks line. */
13
+ reportIds: string[];
8
14
  }
9
15
  /** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
10
- * pending-ask map the caller polls; pass `{}` to omit the ⚑ badges. `palette`
16
+ * pending human-ticket count map the caller polls (deck/review/notify); pass
17
+ * `{}` to omit the ⚑ badges. `palette`
11
18
  * drives the headers + dim cells; omit it and the panels render uncolored. */
12
- export declare function buildCanvasPanelLines(nodeId: string, asks: Record<string, number>, palette?: AttachPalette, source?: CanvasSource): Promise<CanvasPanelLines>;
19
+ export declare function buildCanvasPanelLines(nodeId: string, asks: Record<string, number>, palette?: AttachPalette, source?: CanvasSource, selectedId?: string): Promise<CanvasPanelLines>;
@@ -1,17 +1,24 @@
1
1
  // canvas-panels.ts — the subscribed-node panel for `crtr surface attach` (Unit Q).
2
2
  //
3
3
  // Reads through the CanvasSource seam so the viewer chrome and graph overlay
4
- // share the same canvas read authority. Produces the two line stacks the viewer paints
4
+ // share the same canvas read authority. Produces the line stacks the viewer paints
5
5
  // into Containers around the editor:
6
- // managers → ABOVE the editor: this node's manager (its first subscriber).
7
- // reports → BELOW the editor: this node's live reports (active|idle
8
- // subscriptions), plus a trailing line for self's own asks.
6
+ // managers → always empty: nothing is shown ABOVE the editor. The manager is
7
+ // folded into the one list below (see reports), so the roster is a
8
+ // single stable list, not a split above/below layout.
9
+ // reports → BELOW the editor: the ONE stable family roster — the subagent
10
+ // set's root (self's non-human manager, or self when it is the
11
+ // root) followed by that root's live (active|idle) children — plus
12
+ // a trailing ⚑ line for self's own pending human tickets (asks,
13
+ // reviews, notifications). The SAME list shows for every family
14
+ // member; the one this viewer is attached to is the highlighted
15
+ // row. Backs the attach viewer's inline scroll+swap.
9
16
  //
10
- // Pure: given a node id + the per-node ask map it returns ANSI strings; the
17
+ // Pure: given a node id + the per-node ticket-count map it returns ANSI strings; the
11
18
  // caller owns the Containers and the refresh trigger. beginFrame() clears the
12
19
  // nav-model telemetry cache for this one build pass so token/activity cells are
13
20
  // read fresh for every chrome frame.
14
- import { beginFrame, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, YELLOW, RESET, DIM, GREEN, } from '../../core/canvas/nav-model.js';
21
+ import { beginFrame, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, fillBar, fillWidth, YELLOW, RESET, DIM, GREEN, REVERSE, BG_ATTACHED, } from '../../core/canvas/nav-model.js';
15
22
  import { localCanvasSource } from '../../core/canvas/source.js';
16
23
  import { RemoteCanvasSource } from '../../core/canvas/remote-canvas-source.js';
17
24
  /** Column widths for the aligned panel rows (visible columns). name is sized to
@@ -30,9 +37,10 @@ function padStartVisible(s, w) {
30
37
  return s.length >= w ? s.slice(0, w) : ' '.repeat(w - s.length) + s;
31
38
  }
32
39
  /** Build the BASE panel line stacks for `nodeId`. `asks` is the per-node
33
- * pending-ask map the caller polls; pass `{}` to omit the ⚑ badges. `palette`
40
+ * pending human-ticket count map the caller polls (deck/review/notify); pass
41
+ * `{}` to omit the ⚑ badges. `palette`
34
42
  * drives the headers + dim cells; omit it and the panels render uncolored. */
35
- export async function buildCanvasPanelLines(nodeId, asks, palette, source = localCanvasSource) {
43
+ export async function buildCanvasPanelLines(nodeId, asks, palette, source = localCanvasSource, selectedId) {
36
44
  // A remote source provides only graph structure + node metadata; tokens/
37
45
  // activity/status-glyph adornments are local disk reads (telemetry.json,
38
46
  // fault state, pid liveness) and are suppressed to safe defaults below —
@@ -126,9 +134,20 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source = loca
126
134
  const muted = palette?.muted ?? ((s) => s);
127
135
  const faint = palette?.faint ?? ((s) => s);
128
136
  await computeActivity(await climbRoot(nodeId));
129
- const mgr = await managerOf(nodeId);
130
- const live = await liveReports(nodeId);
131
- const gridIds = [...(mgr !== undefined ? [mgr] : []), ...live];
137
+ // ONE stable family roster, shown BELOW the editor and NOTHING above it
138
+ // (Claude-Code style): the root of this subagent set — self's (non-human)
139
+ // manager, or self when it is the root followed by that root's live children.
140
+ // The SAME list appears no matter which family member you're viewing; the one
141
+ // you're in is the highlighted row. The manager is folded in as the top entry
142
+ // (root-first), so "up to main" is just scrolling up the same list — no
143
+ // separate parent panel above the box. A solo node with no family shows nothing.
144
+ const mgrRaw = await managerOf(nodeId);
145
+ const mgr = mgrRaw !== undefined && (await node(mgrRaw))?.kind !== 'human' ? mgrRaw : undefined;
146
+ const rosterRoot = mgr ?? nodeId;
147
+ const kids = await liveReports(rosterRoot);
148
+ const family = [rosterRoot, ...kids.filter((id) => id !== rosterRoot)];
149
+ const live = family.length > 1 ? family : [];
150
+ const gridIds = live;
132
151
  const gridNodes = await Promise.all(gridIds.map((id) => node(id)));
133
152
  const nameW = gridIds.length > 0
134
153
  ? Math.min(NAME_CAP, Math.max(...gridIds.map((id, i) => navLabel(gridNodes[i] ?? null, id).length)))
@@ -139,16 +158,23 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source = loca
139
158
  const kind = faint(nameCell(n?.kind ?? '', KIND_W));
140
159
  const tokens = muted(padStartVisible(tokensCell(id, remote), TOK_W));
141
160
  const badges = `${cycleBadge(n)}${await childBadgeFor(n)}${liveBelowBadgeFor(n)}${askBadge(id, asks)}${activityCell(id, n, remote)}`;
142
- return truncate(` ${nodeGlyph(n, remote)} ${name} ${kind} ${tokens}${badges}`);
161
+ const line = ` ${nodeGlyph(n, remote)} ${name} ${kind} ${tokens}${badges}`;
162
+ // Two roster highlights: the roving nav cursor (strong REVERSE, mirroring the
163
+ // graph overlay's cursor) wins; otherwise the node this viewer is CURRENTLY
164
+ // attached to — self, when it sits among its siblings — gets the subtler
165
+ // you-are-here bar so you always see which subagent you're in.
166
+ if (id === selectedId)
167
+ return fillBar(line, fillWidth(), REVERSE);
168
+ if (id === nodeId)
169
+ return fillBar(line, fillWidth(), BG_ATTACHED);
170
+ return truncate(line);
143
171
  };
144
- const managers = [];
145
- if (mgr !== undefined)
146
- managers.push(await row(mgr));
147
172
  const reports = [];
148
173
  for (const id of live)
149
174
  reports.push(await row(id));
150
175
  const selfAsks = asks[nodeId] ?? 0;
151
176
  if (selfAsks > 0)
152
177
  reports.push(` ${YELLOW}⚑ ${selfAsks} pending${RESET}`);
153
- return { managers, reports };
178
+ // Nothing above the editor: the whole family lives in the one list below.
179
+ return { managers: [], reports, managerIds: [], reportIds: [...live] };
154
180
  }
@@ -59,6 +59,17 @@ export declare class ChatView {
59
59
  private bashComponent;
60
60
  /** The single active activity loader (working/compaction/retry), if any. */
61
61
  private activityLoader;
62
+ /** True while applySnapshot is mid-build; live broker frames defer instead of
63
+ * mutating the partially-rebuilt view. */
64
+ private applyingSnapshot;
65
+ /** Bumped on every applySnapshot. A build suspended at a yield compares this
66
+ * after resuming and abandons itself when a newer welcome has bumped it, so
67
+ * the newest welcome wins and stale state never overwrites the live view. */
68
+ private snapshotGeneration;
69
+ /** Live frames captured during an in-flight build (bound closures that re-run
70
+ * the original entry point), replayed in order when the build completes and
71
+ * dropped when a newer welcome supersedes the build. */
72
+ private readonly deferredFrames;
62
73
  /** Text of the most recent assistant message, kept current from the welcome
63
74
  * snapshot AND the live event stream so `/copy` (getLastAssistantText) works
64
75
  * whether the message arrived before or after attach. */
@@ -69,7 +80,15 @@ export declare class ChatView {
69
80
  * tool-execution components matched to their toolResult messages; everything
70
81
  * else routes through `addMessageToChat`. The TUI's differential renderer +
71
82
  * reflow-on-resize is automatic — no scrollback reconstruction needed. */
72
- applySnapshot(snapshot: BrokerSnapshot): void;
83
+ applySnapshot(snapshot: BrokerSnapshot): Promise<void>;
84
+ /** Defer a live broker frame when a snapshot build is in flight. Returns true
85
+ * (and enqueues the replay closure) while building so the caller returns
86
+ * without mutating the half-rebuilt view; false when the caller may proceed.
87
+ * This is the single-writer gate every live-frame entry point passes through. */
88
+ private deferIfBuilding;
89
+ /** Replay deferred live frames in wire order. The gate is already open, so each
90
+ * closure re-enters its entry point and dispatches immediately. */
91
+ private replayDeferredFrames;
73
92
  /** Stop any spinning activity loader. A one-shot `crtr surface attach` process lets its
74
93
  * timers die with the process, but if T7 reuses a ChatView across reconnects in
75
94
  * one process it should call this on detach to avoid a leaked interval. */
@@ -40,6 +40,10 @@ const CONTEXT_INTRO_CUSTOM_TYPE = 'crtr-context';
40
40
  * themselves still use `getMarkdownTheme()` for full-fidelity rendering. */
41
41
  const defaultSpinnerStyle = (s) => `\x1b[2m${s}\x1b[22m`;
42
42
  const defaultDimStyle = (s) => `\x1b[2m${s}\x1b[22m`;
43
+ /** How many catch-up messages `applySnapshot` builds between event-loop yields.
44
+ * Keeps large non-streaming snapshots from monopolizing the main thread so
45
+ * queued keystrokes are serviced during construction. */
46
+ const SNAPSHOT_YIELD_EVERY = 50;
43
47
  /** A component supports the Ctrl+O global tool-output toggle. Mirrors pi's
44
48
  * `isExpandable` duck-type (interactive-mode.js): any rendered child carrying a
45
49
  * `setExpanded(boolean)` method — tool executions, custom/compaction/branch/skill
@@ -125,6 +129,25 @@ export class ChatView {
125
129
  bashComponent;
126
130
  /** The single active activity loader (working/compaction/retry), if any. */
127
131
  activityLoader;
132
+ // -- Single-writer snapshot ordering -------------------------------------
133
+ // A non-streaming catch-up yields to the event loop mid-build (so queued stdin
134
+ // drains), which opens a window where later broker frames could otherwise
135
+ // mutate a half-rebuilt transcript. These three fields make the ChatView a
136
+ // single ordered writer: applySnapshot is the only thing that mutates the
137
+ // view while a build is in flight, live frames are deferred and replayed in
138
+ // wire order once it finishes, and a newer welcome always supersedes an older
139
+ // build. See applySnapshot / deferIfBuilding.
140
+ /** True while applySnapshot is mid-build; live broker frames defer instead of
141
+ * mutating the partially-rebuilt view. */
142
+ applyingSnapshot = false;
143
+ /** Bumped on every applySnapshot. A build suspended at a yield compares this
144
+ * after resuming and abandons itself when a newer welcome has bumped it, so
145
+ * the newest welcome wins and stale state never overwrites the live view. */
146
+ snapshotGeneration = 0;
147
+ /** Live frames captured during an in-flight build (bound closures that re-run
148
+ * the original entry point), replayed in order when the build completes and
149
+ * dropped when a newer welcome supersedes the build. */
150
+ deferredFrames = [];
128
151
  /** Text of the most recent assistant message, kept current from the welcome
129
152
  * snapshot AND the live event stream so `/copy` (getLastAssistantText) works
130
153
  * whether the message arrived before or after attach. */
@@ -163,12 +186,34 @@ export class ChatView {
163
186
  * tool-execution components matched to their toolResult messages; everything
164
187
  * else routes through `addMessageToChat`. The TUI's differential renderer +
165
188
  * reflow-on-resize is automatic — no scrollback reconstruction needed. */
166
- applySnapshot(snapshot) {
189
+ async applySnapshot(snapshot) {
190
+ // Open a new build generation. Any older build still suspended at a yield
191
+ // abandons itself when it resumes (generation check below), and live frames
192
+ // that were deferred against the transcript we are about to discard are
193
+ // dropped — a newer welcome always wins.
194
+ const generation = ++this.snapshotGeneration;
195
+ this.applyingSnapshot = true;
196
+ this.deferredFrames.length = 0;
167
197
  this.resetChat();
168
198
  const renderedPendingTools = new Map();
169
199
  const messages = snapshot.messages;
170
200
  const lastIndex = messages.length - 1;
171
201
  for (let i = 0; i < messages.length; i++) {
202
+ // Yield to the event loop every SNAPSHOT_YIELD_EVERY messages so queued
203
+ // stdin drains (keystrokes are handled instead of bursting after a long
204
+ // catch-up) while a big transcript constructs. Skipped for a STREAMING
205
+ // snapshot: its in-flight tail + pending tools must be bound before any
206
+ // relayed message_update/message_end lands, so it builds synchronously
207
+ // (no await → no broker frame can interleave and freeze the live bubble).
208
+ if (!snapshot.state.isStreaming &&
209
+ i > 0 &&
210
+ i % SNAPSHOT_YIELD_EVERY === 0) {
211
+ await new Promise((resolve) => setImmediate(resolve));
212
+ // A newer welcome arrived while we were suspended — abandon this stale
213
+ // build without touching the view (or the gate) it no longer owns.
214
+ if (generation !== this.snapshotGeneration)
215
+ return;
216
+ }
172
217
  const message = messages[i];
173
218
  if (message.role === 'assistant') {
174
219
  // Keep the `/copy` source current from the catch-up transcript too, not
@@ -231,6 +276,28 @@ export class ChatView {
231
276
  if (snapshot.state.isStreaming)
232
277
  this.setActivity(this.makeLoader('Working...'));
233
278
  this.tui.requestRender();
279
+ // Build complete and still current (a mismatch would have returned above):
280
+ // open the gate and replay, in wire order, any live frames that arrived
281
+ // during construction so they land on the finished transcript.
282
+ this.applyingSnapshot = false;
283
+ this.replayDeferredFrames();
284
+ }
285
+ /** Defer a live broker frame when a snapshot build is in flight. Returns true
286
+ * (and enqueues the replay closure) while building so the caller returns
287
+ * without mutating the half-rebuilt view; false when the caller may proceed.
288
+ * This is the single-writer gate every live-frame entry point passes through. */
289
+ deferIfBuilding(replay) {
290
+ if (!this.applyingSnapshot)
291
+ return false;
292
+ this.deferredFrames.push(replay);
293
+ return true;
294
+ }
295
+ /** Replay deferred live frames in wire order. The gate is already open, so each
296
+ * closure re-enters its entry point and dispatches immediately. */
297
+ replayDeferredFrames() {
298
+ const frames = this.deferredFrames.splice(0);
299
+ for (const replay of frames)
300
+ replay();
234
301
  }
235
302
  /** Stop any spinning activity loader. A one-shot `crtr surface attach` process lets its
236
303
  * timers die with the process, but if T7 reuses a ChatView across reconnects in
@@ -274,6 +341,10 @@ export class ChatView {
274
341
  // exactly as interactive pi falls through default for those two).
275
342
  // -------------------------------------------------------------------------
276
343
  handleEvent(event) {
344
+ // Order behind an in-flight catch-up: a live turn that starts while a large
345
+ // welcome is still building is replayed after it, never interleaved into it.
346
+ if (this.deferIfBuilding(() => this.handleEvent(event)))
347
+ return;
277
348
  switch (event.type) {
278
349
  case 'agent_start': {
279
350
  this.pendingTools.clear();
@@ -444,7 +515,13 @@ export class ChatView {
444
515
  default:
445
516
  break;
446
517
  }
447
- this.historyContainer.markDirty();
518
+ // No unconditional markDirty here: history-mutating branches already
519
+ // invalidate the cache the moment they touch the container — appends/removes
520
+ // go through RenderCacheContainer.addChild/removeChild (which markDirty), and
521
+ // every in-place child mutation (message_update/message_end, tool_execution_*)
522
+ // runs while the container is volatile (agent_start set it), where the cache
523
+ // is bypassed entirely. Footer-only, ignored (turn_start/turn_end), and
524
+ // activity-only events thus no longer nuke the settled-history render cache.
448
525
  this.tui.requestRender();
449
526
  }
450
527
  // -------------------------------------------------------------------------
@@ -454,6 +531,8 @@ export class ChatView {
454
531
  // also rides the next snapshot, so this only drives the LIVE view.
455
532
  // -------------------------------------------------------------------------
456
533
  bashStart(command, excludeFromContext) {
534
+ if (this.deferIfBuilding(() => this.bashStart(command, excludeFromContext)))
535
+ return;
457
536
  const component = new BashExecutionComponent(command, this.tui, excludeFromContext);
458
537
  this.bashComponent = component;
459
538
  this.historyContainer.setVolatile(true);
@@ -461,11 +540,15 @@ export class ChatView {
461
540
  this.tui.requestRender();
462
541
  }
463
542
  bashOutput(chunk) {
543
+ if (this.deferIfBuilding(() => this.bashOutput(chunk)))
544
+ return;
464
545
  this.bashComponent?.appendOutput(chunk);
465
546
  this.historyContainer.markDirty();
466
547
  this.tui.requestRender();
467
548
  }
468
549
  bashEnd(result) {
550
+ if (this.deferIfBuilding(() => this.bashEnd(result)))
551
+ return;
469
552
  this.bashComponent?.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true } : undefined, result.fullOutputPath);
470
553
  this.bashComponent = undefined;
471
554
  this.historyContainer.setVolatile(false);
@@ -220,12 +220,15 @@ describe('human cancel: behavior', () => {
220
220
  assert.equal(r['canceled'], false);
221
221
  assert.equal(r['reason'], 'already_resolved');
222
222
  });
223
- test('live node with no paneretires the node (status done)', async () => {
223
+ test('live node with no request descriptor already_resolved (nothing to cancel)', async () => {
224
+ // A live bridge whose interaction dir holds no deck.json/review.json has no
225
+ // humanloop ticket to race a cancellation against, so the !hasDescriptor
226
+ // guard short-circuits to already_resolved and never transitions the node.
224
227
  createNode(humanNode('live-1', { status: 'active' }));
225
228
  const r = await humanCancel.run({ job_id: 'live-1' });
226
- assert.equal(r['canceled'], true);
227
- assert.equal(r['job_id'], 'live-1');
228
- assert.equal(getNode('live-1')?.status, 'done');
229
+ assert.equal(r['canceled'], false);
230
+ assert.equal(r['reason'], 'already_resolved');
231
+ assert.equal(getNode('live-1')?.status, 'active');
229
232
  });
230
233
  });
231
234
  // ---------------------------------------------------------------------------
@@ -253,21 +256,3 @@ describe('human list: params', () => {
253
256
  assert.equal(result['cursor'], 'tok_abc123');
254
257
  });
255
258
  });
256
- // ---------------------------------------------------------------------------
257
- // human _run — no params (reads CRTR_HUMAN_DIR from env)
258
- // ---------------------------------------------------------------------------
259
- describe('human _run: no params', () => {
260
- const params = [];
261
- test('empty argv yields empty result (no params declared)', async () => {
262
- const result = await parseArgv(params, []);
263
- assert.deepEqual(result, {});
264
- });
265
- test('positional token throws bad_invocation (no positionals declared)', async () => {
266
- await assert.rejects(() => parseArgv(params, ['some-value']), (err) => { assert.match(err.message, /takes no positional/); return true; });
267
- });
268
- test('CRTR_HUMAN_DIR env var contract: _run reads it from env, not argv', () => {
269
- // Structural check: the params array is empty, confirming no argv surface.
270
- // The actual env-var read is in the run handler and verified by inspection.
271
- assert.equal(params.length, 0);
272
- });
273
- });
@@ -1,51 +1,52 @@
1
- // `crtr canvas attention` — aggregate pending human asks across the canvas.
1
+ // `crtr canvas attention` — aggregate pending human tickets across the canvas.
2
2
  //
3
- // Pending asks are stored per-cwd by humanloop. This subtree surfaces two
4
- // public views:
3
+ // A pending "ticket" is any humanloop interaction awaiting a human: a deck
4
+ // (ask), a notify deck, or an anchored review. They are stored per-cwd by
5
+ // humanloop. This subtree surfaces two public views:
5
6
  // count — a single integer (stdout.count is machine-parseable)
6
- // list — itemised entries with cwd, node id, and per-cwd ask count
7
+ // list — itemised entries with cwd, node id, and per-cwd ticket count
7
8
  // `map` (hidden) is the batched per-node form the attach viewer's nav chrome
8
9
  // polls; it is not an agent-facing decision.
9
10
  //
10
11
  // All three scope modes share the same underlying helpers in attention.ts:
11
- // --node <id> → countAsks(id) (one cwd)
12
- // --view <id> → pendingAsksForView(id) (sub-DAG from root)
13
- // (neither) → asksAcrossCanvas() (whole canvas)
12
+ // --node <id> → countTickets(id) (one cwd)
13
+ // --view <id> → pendingTicketsForView(id) (sub-DAG from root)
14
+ // (neither) → ticketsAcrossCanvas() (whole canvas)
14
15
  //
15
16
  // Exported as a branch; `crtr canvas` (canvas.ts) mounts it.
16
17
  import { defineBranch, defineLeaf } from '../core/command.js';
17
18
  import { InputError } from '../core/io.js';
18
19
  import { getNode, view } from '../core/canvas/index.js';
19
- import { countAsks, pendingAsksForView, asksAcrossCanvas, asksForNodes, } from '../core/canvas/attention.js';
20
+ import { countTickets, pendingTicketsForView, ticketsAcrossCanvas, ticketCountsForNodes, } from '../core/canvas/attention.js';
20
21
  // ---------------------------------------------------------------------------
21
22
  // attention count
22
23
  // ---------------------------------------------------------------------------
23
24
  const attentionCount = defineLeaf({
24
25
  name: 'count',
25
- description: 'total pending ask count (machine-parseable stdout.count)',
26
+ description: 'total pending human-ticket count (machine-parseable stdout.count)',
26
27
  whenToUse: 'a lightweight summary when you just need the number, not which cwds are blocked — scope it to a node or a sub-DAG, or default to canvas-wide. Use `list` when you need to act on where to go answer.',
27
28
  help: {
28
29
  name: 'canvas attention count',
29
30
  // stdout.count is parsed directly by the nav chrome — keep the contract.
30
- summary: 'return the number of pending human asks; stdout.count is machine-parseable',
31
+ summary: 'return the number of pending human tickets (asks, reviews, notifications); stdout.count is machine-parseable',
31
32
  params: [
32
33
  {
33
34
  kind: 'flag',
34
35
  name: 'node',
35
36
  type: 'string',
36
37
  required: false,
37
- constraint: 'Count asks only for this node\'s cwd. Mutually exclusive with --view.',
38
+ constraint: 'Count tickets only for this node\'s cwd. Mutually exclusive with --view.',
38
39
  },
39
40
  {
40
41
  kind: 'flag',
41
42
  name: 'view',
42
43
  type: 'string',
43
44
  required: false,
44
- constraint: 'Sum asks for all nodes in the sub-DAG rooted at this id. Mutually exclusive with --node.',
45
+ constraint: 'Sum tickets for all nodes in the sub-DAG rooted at this id. Mutually exclusive with --node.',
45
46
  },
46
47
  ],
47
48
  output: [
48
- { name: 'count', type: 'integer', required: true, constraint: 'Total pending asks in the requested scope.' },
49
+ { name: 'count', type: 'integer', required: true, constraint: 'Total pending human tickets in the requested scope.' },
49
50
  ],
50
51
  outputKind: 'object',
51
52
  effects: ['Read-only: scans humanloop interaction dirs.'],
@@ -69,7 +70,7 @@ const attentionCount = defineLeaf({
69
70
  next: 'List nodes with `crtr node inspect list`.',
70
71
  });
71
72
  }
72
- return { count: countAsks(nodeId) };
73
+ return { count: countTickets(nodeId) };
73
74
  }
74
75
  if (viewId !== undefined) {
75
76
  if (getNode(viewId) === null) {
@@ -79,12 +80,12 @@ const attentionCount = defineLeaf({
79
80
  next: 'List nodes with `crtr node inspect list`.',
80
81
  });
81
82
  }
82
- const items = pendingAsksForView(viewId);
83
+ const items = pendingTicketsForView(viewId);
83
84
  const total = items.reduce((s, e) => s + e.count, 0);
84
85
  return { count: total };
85
86
  }
86
87
  // Canvas-wide.
87
- const items = asksAcrossCanvas();
88
+ const items = ticketsAcrossCanvas();
88
89
  const total = items.reduce((s, e) => s + e.count, 0);
89
90
  return { count: total };
90
91
  },
@@ -94,11 +95,11 @@ const attentionCount = defineLeaf({
94
95
  // ---------------------------------------------------------------------------
95
96
  const attentionList = defineLeaf({
96
97
  name: 'list',
97
- description: 'itemised list of cwds with pending asks',
98
- whenToUse: 'finding which agents are blocked waiting on a human — an itemised list of the cwds with pending asks, oldest first, so you know where to go answer. Scope it to a sub-DAG or list canvas-wide. Use `canvas attention count` instead when you just need the number.',
98
+ description: 'itemised list of cwds with pending human tickets',
99
+ whenToUse: 'finding which agents are blocked waiting on a human — an itemised list of the cwds with pending tickets (asks, reviews, notifications), oldest first, so you know where to go answer. Scope it to a sub-DAG or list canvas-wide. Use `canvas attention count` instead when you just need the number.',
99
100
  help: {
100
101
  name: 'canvas attention list',
101
- summary: 'list nodes with pending human asks, grouped by cwd, oldest first',
102
+ summary: 'list nodes with pending human tickets, grouped by cwd, oldest first',
102
103
  params: [
103
104
  {
104
105
  kind: 'flag',
@@ -131,10 +132,10 @@ const attentionList = defineLeaf({
131
132
  next: 'List nodes with `crtr node inspect list`.',
132
133
  });
133
134
  }
134
- items = pendingAsksForView(viewId);
135
+ items = pendingTicketsForView(viewId);
135
136
  }
136
137
  else {
137
- items = asksAcrossCanvas();
138
+ items = ticketsAcrossCanvas();
138
139
  }
139
140
  const total = items.reduce((s, e) => s + e.count, 0);
140
141
  return { items, total };
@@ -145,12 +146,12 @@ const attentionList = defineLeaf({
145
146
  // ---------------------------------------------------------------------------
146
147
  const attentionMap = defineLeaf({
147
148
  name: 'map',
148
- description: 'per-node ask counts for a visible set, batched in one pass',
149
- whenToUse: 'labelling every node in a UI with its pending-ask count in one batched pass — the form nav chrome polls (one process, one JSON blob) instead of N count shell-outs',
149
+ description: 'per-node ticket counts for a visible set, batched in one pass',
150
+ whenToUse: 'labelling every node in a UI with its pending-ticket count in one batched pass — the form nav chrome polls (one process, one JSON blob) instead of N count shell-outs',
150
151
  tier: 'hidden',
151
152
  help: {
152
153
  name: 'canvas attention map',
153
- summary: 'per-node pending-ask counts for a visible set of nodes in ONE pass — the batched form the nav chrome polls (one process, one JSON blob) instead of N count shell-outs',
154
+ summary: 'per-node pending human-ticket counts for a visible set of nodes in ONE pass — the batched form the nav chrome polls (one process, one JSON blob) instead of N count shell-outs',
154
155
  params: [
155
156
  {
156
157
  kind: 'flag',
@@ -172,7 +173,7 @@ const attentionMap = defineLeaf({
172
173
  name: 'counts',
173
174
  type: 'object',
174
175
  required: true,
175
- constraint: 'Map of node_id → pending ask count. Every requested id is present (0 when none).',
176
+ constraint: 'Map of node_id → pending human-ticket count. Every requested id is present (0 when none).',
176
177
  },
177
178
  ],
178
179
  outputKind: 'object',
@@ -205,7 +206,7 @@ const attentionMap = defineLeaf({
205
206
  for (const id of nodesRaw.split(',').map((s) => s.trim()).filter((s) => s !== ''))
206
207
  ids.add(id);
207
208
  }
208
- return { counts: asksForNodes([...ids]) };
209
+ return { counts: ticketCountsForNodes([...ids]) };
209
210
  },
210
211
  });
211
212
  // ---------------------------------------------------------------------------
@@ -213,12 +214,12 @@ const attentionMap = defineLeaf({
213
214
  // ---------------------------------------------------------------------------
214
215
  export const attentionBranch = defineBranch({
215
216
  name: 'attention',
216
- description: 'count/list pending human asks across the graph',
217
- whenToUse: 'checking whether any agent on the canvas is blocked waiting on a human, and where: count the pending asks or list the cwds that have them. Scope it to a node or a sub-DAG, or go canvas-wide. Use `canvas dashboard` instead for the graph SHAPE, or `node inspect list` for a plain node roster',
217
+ description: 'count/list pending human tickets across the graph',
218
+ whenToUse: 'checking whether any agent on the canvas is blocked waiting on a human, and where: count the pending tickets (asks, reviews, notifications) or list the cwds that have them. Scope it to a node or a sub-DAG, or go canvas-wide. Use `canvas dashboard` instead for the graph SHAPE, or `node inspect list` for a plain node roster',
218
219
  help: {
219
220
  name: 'canvas attention',
220
- summary: 'aggregate pending human asks across the canvas',
221
- model: '`count` returns a single integer (stdout.count is machine-parseable); `list` returns itemised entries — the cwds with pending asks, oldest first. Scope with --node (one node) or --view (sub-DAG) — default is canvas-wide.',
221
+ summary: 'aggregate pending human tickets across the canvas',
222
+ model: '`count` returns a single integer (stdout.count is machine-parseable); `list` returns itemised entries — the cwds with pending tickets, oldest first. Scope with --node (one node) or --view (sub-DAG) — default is canvas-wide. A ticket is any humanloop interaction awaiting a human: an ask, a review, or a notification.',
222
223
  },
223
224
  children: [attentionCount, attentionList, attentionMap],
224
225
  });