@north-light/crouter 0.3.196 → 0.3.198

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 (148) hide show
  1. package/dist/api/client.d.ts +2 -2
  2. package/dist/api/dto/inbox.d.ts +46 -0
  3. package/dist/builtin-memory/00-runtime-base.md +3 -3
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  5. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  7. package/dist/clients/attach/render/chat-view.d.ts +3 -8
  8. package/dist/clients/attach/render/chat-view.js +3 -24
  9. package/dist/clients/attach/render/markdown-source.js +0 -6
  10. package/dist/clients/attach/render/tool-calls.js +16 -2
  11. package/dist/clients/attach/viewer.js +613 -602
  12. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
  13. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
  14. package/dist/clients/inbox/controller.js +4 -4
  15. package/dist/clients/inbox/page-adapter.d.ts +2 -1
  16. package/dist/clients/inbox/tui/render.js +46 -25
  17. package/dist/clients/inbox/tui/slots.d.ts +1 -3
  18. package/dist/clients/inbox/tui/slots.js +8 -37
  19. package/dist/clients/inbox/tui/types.d.ts +4 -3
  20. package/dist/commands/__tests__/human.test.js +160 -23
  21. package/dist/commands/canvas-browse.js +1 -1
  22. package/dist/commands/human/doc.js +21 -74
  23. package/dist/commands/human/html.d.ts +2 -0
  24. package/dist/commands/human/html.js +81 -0
  25. package/dist/commands/human/prompts.d.ts +5 -4
  26. package/dist/commands/human/prompts.js +90 -168
  27. package/dist/commands/human/shared.d.ts +5 -6
  28. package/dist/commands/human/shared.js +13 -61
  29. package/dist/commands/human.js +7 -2
  30. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  31. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  32. package/dist/core/__tests__/human-deliver.test.js +28 -37
  33. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  34. package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
  35. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
  36. package/dist/core/canvas/__tests__/attention.test.js +2 -2
  37. package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
  38. package/dist/core/canvas/browse/app.d.ts +1 -1
  39. package/dist/core/canvas/browse/app.js +21 -3
  40. package/dist/core/canvas/browse/render.d.ts +4 -0
  41. package/dist/core/canvas/browse/render.js +5 -1
  42. package/dist/core/canvas/canvas.d.ts +26 -0
  43. package/dist/core/canvas/canvas.js +55 -0
  44. package/dist/core/command.js +6 -11
  45. package/dist/core/config.js +3 -2
  46. package/dist/core/help.d.ts +3 -0
  47. package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
  48. package/dist/core/human/__tests__/html-markdown.test.js +52 -0
  49. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  50. package/dist/core/human/__tests__/page-catalog.test.js +16 -0
  51. package/dist/core/human/__tests__/page-render.test.js +40 -23
  52. package/dist/core/human/__tests__/page-tickets.test.js +48 -27
  53. package/dist/core/human/__tests__/page.test.js +48 -65
  54. package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
  55. package/dist/core/human/answer-text.d.ts +3 -0
  56. package/dist/core/human/answer-text.js +98 -0
  57. package/dist/core/human/answer.d.ts +51 -0
  58. package/dist/core/human/answer.js +125 -0
  59. package/dist/core/human/component-docs.d.ts +14 -0
  60. package/dist/core/human/component-docs.js +161 -0
  61. package/dist/core/human/html-markdown.d.ts +3 -0
  62. package/dist/core/human/html-markdown.js +288 -0
  63. package/dist/core/human/page-catalog.d.ts +19 -3
  64. package/dist/core/human/page-catalog.js +73 -16
  65. package/dist/core/human/page-render.d.ts +3 -2
  66. package/dist/core/human/page-render.js +19 -45
  67. package/dist/core/human/page-schema.d.ts +108 -8
  68. package/dist/core/human/page-schema.js +88 -31
  69. package/dist/core/human/page-synth.d.ts +2 -4
  70. package/dist/core/human/page-synth.js +23 -7
  71. package/dist/core/human/page.d.ts +14 -7
  72. package/dist/core/human/page.js +178 -90
  73. package/dist/core/human/review-schema.d.ts +1 -0
  74. package/dist/core/human/review-schema.js +1 -1
  75. package/dist/core/human/scan.d.ts +3 -2
  76. package/dist/core/human/scan.js +7 -8
  77. package/dist/core/human/summary.d.ts +4 -7
  78. package/dist/core/human/summary.js +4 -60
  79. package/dist/core/human/tickets.d.ts +10 -9
  80. package/dist/core/human/tickets.js +5 -3
  81. package/dist/core/human/types.d.ts +4 -0
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +1 -0
  84. package/dist/core/review/realize.js +9 -1
  85. package/dist/core/runtime/boot-root.js +6 -1
  86. package/dist/core/runtime/broker.js +8 -6
  87. package/dist/core/runtime/busy.d.ts +4 -3
  88. package/dist/core/runtime/busy.js +4 -3
  89. package/dist/core/runtime/spawn.js +4 -4
  90. package/dist/core/termrender/display.d.ts +7 -3
  91. package/dist/core/termrender/display.js +45 -9
  92. package/dist/core/termrender/termrender.d.ts +3 -12
  93. package/dist/core/termrender/termrender.js +6 -37
  94. package/dist/core/user-settings.d.ts +17 -2
  95. package/dist/core/user-settings.js +32 -11
  96. package/dist/daemon/api/handlers/human.js +1 -1
  97. package/dist/daemon/api/handlers/inbox.js +22 -7
  98. package/dist/daemon/cron-run.js +16 -2
  99. package/dist/daemon/crtrd.js +1 -1
  100. package/dist/daemon/fleet.d.ts +22 -10
  101. package/dist/daemon/fleet.js +43 -24
  102. package/dist/daemon/human/finish.d.ts +2 -1
  103. package/dist/daemon/human/finish.js +45 -28
  104. package/dist/daemon/human/sweep.js +4 -2
  105. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  106. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  107. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  108. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  109. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  110. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  111. package/dist/daemon/review/sweep.js +12 -1
  112. package/dist/pages/bundle.css +1 -1
  113. package/dist/pages/bundle.js +1008 -577
  114. package/dist/pages/comments.d.ts +29 -0
  115. package/dist/pages/comments.js +51 -0
  116. package/dist/pages/controls.d.ts +98 -0
  117. package/dist/pages/controls.js +261 -0
  118. package/dist/pages/elements/cards.d.ts +15 -4
  119. package/dist/pages/elements/cards.js +299 -186
  120. package/dist/pages/elements/chart.d.ts +22 -0
  121. package/dist/pages/elements/chart.js +107 -91
  122. package/dist/pages/elements/options.d.ts +9 -0
  123. package/dist/pages/elements/options.js +268 -155
  124. package/dist/pages/elements/pages.d.ts +6 -0
  125. package/dist/pages/elements/pages.js +60 -38
  126. package/dist/pages/elements/slot.d.ts +5 -0
  127. package/dist/pages/elements/slot.js +17 -10
  128. package/dist/pages/elements/table.d.ts +17 -0
  129. package/dist/pages/elements/table.js +336 -228
  130. package/dist/pages/elements/text.d.ts +9 -3
  131. package/dist/pages/elements/text.js +350 -143
  132. package/dist/pages/host.d.ts +0 -24
  133. package/dist/pages/host.js +0 -17
  134. package/dist/pages/readonly.d.ts +24 -0
  135. package/dist/pages/readonly.js +31 -0
  136. package/dist/pages/responses.d.ts +37 -0
  137. package/dist/pages/responses.js +56 -0
  138. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  139. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  140. package/dist/types.d.ts +19 -5
  141. package/dist/types.js +2 -1
  142. package/package.json +1 -1
  143. package/runtime.lock.json +2 -2
  144. package/scripts/install-runtime.mjs +19 -24
  145. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  146. package/dist/clients/attach/render/html-markdown.js +0 -206
  147. package/dist/core/human/markdown-html.d.ts +0 -8
  148. package/dist/core/human/markdown-html.js +0 -581
@@ -20,7 +20,7 @@ const BROWSE_NAV_ACTIONS = [
20
20
  'crtr.browse.tab-next', 'crtr.browse.tab-previous', 'crtr.browse.tab.1', 'crtr.browse.tab.2',
21
21
  'crtr.browse.tab.3', 'crtr.browse.tab.4', 'crtr.browse.tab.5', 'crtr.browse.close-subtree', 'crtr.browse.sort',
22
22
  'crtr.browse.scope', 'crtr.browse.profile', 'crtr.browse.residents', 'crtr.browse.preview', 'crtr.browse.search',
23
- 'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background',
23
+ 'crtr.browse.revive-now', 'crtr.browse.focus', 'crtr.browse.focus-background', 'crtr.browse.yank-id',
24
24
  ];
25
25
  const BROWSE_SEARCH_ACTIONS = [
26
26
  'crtr.browse.search.cancel', 'crtr.browse.search.commit', 'crtr.browse.search.delete',
@@ -39,6 +39,7 @@ export function resolveBrowseSearchAction(bindings, raw) {
39
39
  // press restores the structural `tree` view, then relevance/recency, then back.
40
40
  const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
41
41
  import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './render.js';
42
+ import { writeClipboardTextEverywhere } from '../../clipboard-text.js';
42
43
  /** Viewport (body) height = total rows minus the header renderFrame draws (see
43
44
  * render.ts headerHeight), the footer, and the preview panel when shown. Kept
44
45
  * in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
@@ -102,7 +103,9 @@ export async function runBrowse(opts) {
102
103
  // canvas-wide because there is no truthful profile to select.
103
104
  const launchProfileId = opts.profileId;
104
105
  const state = {
105
- tab: 'All',
106
+ // Open on Live (active|idle): the navigator's job is getting you back to a
107
+ // node you are working with, and All buries those under every dormant row.
108
+ tab: 'Live',
106
109
  cursor: 0,
107
110
  // Initial collapse = every node with children → only roots/top-level show.
108
111
  collapsed: new Set([...tree.nodes.entries()].filter(([, n]) => n.childIds.length > 0).map(([id]) => id)),
@@ -115,6 +118,7 @@ export async function runBrowse(opts) {
115
118
  preview: true, // default ON (decision)
116
119
  residentsOnly: true, // default ON: hide one-shot workers (decision)
117
120
  pendingClose: null,
121
+ flash: null,
118
122
  pinned: loadPins(),
119
123
  help: false,
120
124
  rename: false,
@@ -242,7 +246,7 @@ export async function runBrowse(opts) {
242
246
  tree, visible, tab: state.tab, cursor: state.cursor, scrollOffset: state.scrollOffset,
243
247
  query: state.query, search: state.search, totalNodes,
244
248
  cwdScope: state.cwdScope, profileScope: state.profileScope, sort: state.sort, preview: state.preview,
245
- residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
249
+ residentsOnly: state.residentsOnly, pendingClose: state.pendingClose, flash: state.flash,
246
250
  pinned: state.pinned, help: state.help,
247
251
  rename: state.rename, renameBuf: state.renameBuf,
248
252
  message: state.message, messageBuf: state.messageBuf,
@@ -542,6 +546,8 @@ export async function runBrowse(opts) {
542
546
  const onKeyNav = (input, key) => {
543
547
  const raw = { input, ...key };
544
548
  const action = resolveBrowseNavAction(bindings, raw);
549
+ // Any keystroke retires the previous one-shot footer confirmation.
550
+ state.flash = null;
545
551
  // Unbound ctrl-combos are swallowed so they never masquerade as printable commands.
546
552
  if (key.ctrl && action === null)
547
553
  return;
@@ -756,6 +762,18 @@ export async function runBrowse(opts) {
756
762
  scheduleFlush();
757
763
  return;
758
764
  }
765
+ // Yank the hovered node's id to the system clipboard, so it can be pasted into
766
+ // a prompt or another command without retyping it off the screen.
767
+ if (action === 'crtr.browse.yank-id') {
768
+ if (row !== undefined) {
769
+ const { system, tmux } = writeClipboardTextEverywhere(row.id);
770
+ state.flash = system || tmux
771
+ ? `Copied ${row.id}${system ? '' : ' to the tmux buffer'}`
772
+ : 'No clipboard tool available — nothing copied';
773
+ }
774
+ scheduleFlush();
775
+ return;
776
+ }
759
777
  // Resume the selected node.
760
778
  if (action === 'crtr.browse.focus') {
761
779
  if (row !== undefined)
@@ -43,6 +43,10 @@ export interface RenderState {
43
43
  * a descendant) is actively streaming. Holds the node id being confirmed; the
44
44
  * footer becomes the y/n prompt. null/undefined = no pending confirm. */
45
45
  pendingClose?: string | null;
46
+ /** One-shot footer confirmation for a keystroke whose whole effect is off-screen
47
+ * (`y` yank). Replaces the nav hints for exactly one frame; the app clears it on
48
+ * the next key. null/undefined = show the ordinary footer. */
49
+ flash?: string | null;
46
50
  /** Set when the source is a remote canvas: mutation/focus keys (`x`/`K`/Enter)
47
51
  * are disabled, so the footer drops their hints instead of advertising a
48
52
  * dead action. undefined/false = local, byte-identical footer. */
@@ -428,7 +428,8 @@ const HELP_ROWS = [
428
428
  ['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
429
429
  ['Open', '⏎ o', 'resume the node (closes navigator)'],
430
430
  ['', 'O', 'resume, keep browsing (open in background)'],
431
- ['Act', 'm', 'message the node (no resume)'],
431
+ ['Act', 'y', 'copy the node id to the clipboard'],
432
+ ['', 'm', 'message the node (no resume)'],
432
433
  ['', 'R', 'rename the node'],
433
434
  ['', 'x', 'close-out node + subtree'],
434
435
  ['', 'K', 'revive now (a hanging node)'],
@@ -533,6 +534,9 @@ export function renderFrame(state, size, caps = { color: false, color256: false
533
534
  const warn = `⚠ ${who} is ⟳ — close it (and its subtree) anyway? y / n`;
534
535
  lines.push(caps.color ? `${BOLD}${ESC}${FG_BRIGHT_YELLOW}m${clip(warn, width)}${RESET}` : `${REVERSE}${clip(warn, width)}${RESET}`);
535
536
  }
537
+ else if (state.flash !== null && state.flash !== undefined && state.flash !== '') {
538
+ lines.push(caps.color ? `${BOLD}${clip(state.flash, width)}${RESET}` : `${REVERSE}${clip(state.flash, width)}${RESET}`);
539
+ }
536
540
  else {
537
541
  // When the cursor row has an active fault, lead the footer with the on-demand kick
538
542
  // hint (K shells `node lifecycle revive <id> --now` — immediate revive instead of
@@ -206,6 +206,11 @@ export declare function view(root: string): string[];
206
206
  * that's still live (active|idle) — i.e. something that can actually wake it?
207
207
  * If so, stopping is a legitimate await; if not, it must finish or escalate. */
208
208
  export declare function hasActiveLiveSubscription(nodeId: string): boolean;
209
+ /** Whether a LIVE node still subscribes to this one. The mirror of
210
+ * `hasActiveLiveSubscription`: that asks what can still wake this node, this
211
+ * asks what this node still owes — a subscriber that is itself active or idle
212
+ * is waiting on a report this node has not delivered. */
213
+ export declare function hasLiveSubscriber(nodeId: string): boolean;
209
214
  /** Whether a node explicitly awaits a controller that can still deliver. A
210
215
  * parent relationship never implies this wait; the node must declare it. */
211
216
  export declare function hasLiveMessageWait(nodeId: string): boolean;
@@ -324,6 +329,27 @@ export declare function pruneNodes(opts: {
324
329
  dryRun?: boolean;
325
330
  includeStale?: boolean;
326
331
  }): PruneResult;
332
+ /** Drop rows whose on-disk `nodes/<id>/` dir is gone — a node that no longer
333
+ * exists in the only place it is durably stored.
334
+ *
335
+ * `createNode` scaffolds the dir and writes meta.json BEFORE seeding the row, and
336
+ * both delete paths ({@link deleteNode}, `deletePrunedNodes`) commit the row
337
+ * removal BEFORE removing the dir, so a row with no dir is never a transient of
338
+ * either — it is a node whose durable state was lost. Nothing about it is
339
+ * recoverable: `getNode` reads meta.json and returns null, so every surface that
340
+ * resolves a node (revive, focus, close, rename, message) rejects it as unknown
341
+ * while the roster keeps listing it. The row IS the residue, so deleting it is
342
+ * the whole repair; there is no dir left to remove.
343
+ *
344
+ * Because `active`/`idle` ghosts are the ones that jam surfaces, status is not a
345
+ * filter here — count-based pruning protects those rows precisely so daemon
346
+ * crash-recovery can resume them, and that reason does not survive the dir being
347
+ * gone.
348
+ *
349
+ * Refuses to act on "can't tell": an unreadable nodes root, or a readable one
350
+ * holding no node dirs at all while rows exist, reads as a detached or wiped
351
+ * store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
352
+ export declare function reapGhostRows(): string[];
327
353
  /** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
328
354
  * nodes plus every protected node, so the real retained floor is
329
355
  * `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the
@@ -591,6 +591,18 @@ export function hasActiveLiveSubscription(nodeId) {
591
591
  .get(nodeId);
592
592
  return r !== undefined;
593
593
  }
594
+ /** Whether a LIVE node still subscribes to this one. The mirror of
595
+ * `hasActiveLiveSubscription`: that asks what can still wake this node, this
596
+ * asks what this node still owes — a subscriber that is itself active or idle
597
+ * is waiting on a report this node has not delivered. */
598
+ export function hasLiveSubscriber(nodeId) {
599
+ const r = openDb()
600
+ .prepare(`SELECT 1 FROM edges e JOIN nodes n ON n.node_id = e.from_id
601
+ WHERE e.type = 'subscribes_to' AND e.to_id = ? AND e.active = 1
602
+ AND n.status IN ('active', 'idle') LIMIT 1`)
603
+ .get(nodeId);
604
+ return r !== undefined;
605
+ }
594
606
  /** Whether a node explicitly awaits a controller that can still deliver. A
595
607
  * parent relationship never implies this wait; the node must declare it. */
596
608
  export function hasLiveMessageWait(nodeId) {
@@ -789,6 +801,49 @@ export function pruneNodes(opts) {
789
801
  deletePrunedNodes(candidates);
790
802
  return { pruned: candidates, dryRun };
791
803
  }
804
+ /** Drop rows whose on-disk `nodes/<id>/` dir is gone — a node that no longer
805
+ * exists in the only place it is durably stored.
806
+ *
807
+ * `createNode` scaffolds the dir and writes meta.json BEFORE seeding the row, and
808
+ * both delete paths ({@link deleteNode}, `deletePrunedNodes`) commit the row
809
+ * removal BEFORE removing the dir, so a row with no dir is never a transient of
810
+ * either — it is a node whose durable state was lost. Nothing about it is
811
+ * recoverable: `getNode` reads meta.json and returns null, so every surface that
812
+ * resolves a node (revive, focus, close, rename, message) rejects it as unknown
813
+ * while the roster keeps listing it. The row IS the residue, so deleting it is
814
+ * the whole repair; there is no dir left to remove.
815
+ *
816
+ * Because `active`/`idle` ghosts are the ones that jam surfaces, status is not a
817
+ * filter here — count-based pruning protects those rows precisely so daemon
818
+ * crash-recovery can resume them, and that reason does not survive the dir being
819
+ * gone.
820
+ *
821
+ * Refuses to act on "can't tell": an unreadable nodes root, or a readable one
822
+ * holding no node dirs at all while rows exist, reads as a detached or wiped
823
+ * store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
824
+ export function reapGhostRows() {
825
+ const rows = listNodes();
826
+ if (rows.length === 0)
827
+ return [];
828
+ let onDisk;
829
+ try {
830
+ onDisk = new Set(readdirSync(nodesRoot()));
831
+ }
832
+ catch {
833
+ return []; // nodes root absent or unreadable — can't tell, so touch nothing
834
+ }
835
+ if (onDisk.size === 0)
836
+ return [];
837
+ const ghosts = rows.map((r) => r.node_id).filter((id) => !onDisk.has(id));
838
+ if (ghosts.length === 0)
839
+ return [];
840
+ withCanvasWrite((db) => {
841
+ const del = db.prepare('DELETE FROM nodes WHERE node_id = ?');
842
+ for (const id of ghosts)
843
+ del.run(id);
844
+ });
845
+ return ghosts;
846
+ }
792
847
  /** Hard-delete a set of pruned nodes in one transaction (the edges→nodes and
793
848
  * anchored-crons FKs cascade their coupled state), followed by best-effort
794
849
  * on-disk `nodes/<id>/` removal. Shared by TTL and count pruning so their
@@ -439,9 +439,8 @@ export async function parseArgv(params, tokens, options) {
439
439
  throw parseArgvError('bad_invocation', `unexpected extra positional argument: ${token}`, tokens.join(' '), undefined, 'Use --flag for parameters; only one positional allowed.');
440
440
  }
441
441
  // A bare positional is accepted when the leaf declares a positional param,
442
- // OR when it declares a stdin param (the positional supplies the stdin
443
- // value as an ergonomic alternative to piping). Otherwise it's an error.
444
- if (positionalParam === undefined && stdinParam === undefined) {
442
+ // or when its stdin parameter explicitly permits a positional body.
443
+ if (positionalParam === undefined && (stdinParam === undefined || stdinParam.allowPositional === false)) {
445
444
  throw parseArgvError('bad_invocation', `this leaf takes no positional arguments: ${token}`, token, undefined, 'Use --flag for parameters. Run -h for the schema.');
446
445
  }
447
446
  positionalValues.push(token);
@@ -454,16 +453,12 @@ export async function parseArgv(params, tokens, options) {
454
453
  ? positionalValues
455
454
  : positionalValues[0];
456
455
  }
457
- // Resolve stdin if declared. A positional token (when there's no dedicated
458
- // positional param to claim it) satisfies the stdin param directly, so
459
- // `crtr node new "Say hi"` works as well as piping on stdin. `-` is the
460
- // standard Unix convention for "read stdin instead" — it always reads,
461
- // never becomes the literal value. And a positional alongside genuinely
462
- // piped, non-empty stdin is ambiguous: never silently pick one, error loud
463
- // and name both sources.
456
+ // Resolve stdin if declared. Some leaves permit one positional token to
457
+ // supply the stdin body; `-` then means read stdin. A positional alongside
458
+ // genuinely piped, non-empty stdin is ambiguous and errors loudly.
464
459
  let stdinWasSupplied = false;
465
460
  if (stdinParam !== undefined) {
466
- if (positionalValues.length > 0 && positionalParam === undefined) {
461
+ if (positionalValues.length > 0 && positionalParam === undefined && stdinParam.allowPositional !== false) {
467
462
  const positionalValue = positionalValues[0];
468
463
  if (positionalValue === '-') {
469
464
  const raw = await readStdinRaw();
@@ -479,8 +479,8 @@ function mergeConfig(partial) {
479
479
  ? [...defaults.working_gerunds]
480
480
  : normalizeWorkingGerunds(partial.working_gerunds);
481
481
  const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
482
- const html_pages = typeof partial.html_pages === 'boolean' ? partial.html_pages : defaults.html_pages;
483
482
  const page_components = normalizePageComponents(partial.page_components);
483
+ const html_surface = typeof partial.html_surface === 'boolean' ? partial.html_surface : defaults.html_surface;
484
484
  const whip_messages = normalizeWhipMessages(partial.whip_messages);
485
485
  const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
486
486
  ? partial.mouse_mode_default
@@ -489,6 +489,7 @@ function mergeConfig(partial) {
489
489
  const condensed_history = CONDENSED_HISTORY_MODES.includes(partial.condensed_history)
490
490
  ? partial.condensed_history
491
491
  : defaults.condensed_history;
492
+ const bash_tool_purpose = typeof partial.bash_tool_purpose === 'boolean' ? partial.bash_tool_purpose : defaults.bash_tool_purpose;
492
493
  const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
493
494
  const summarize_tool_calls = typeof partial.summarize_tool_calls === 'boolean' ? partial.summarize_tool_calls : defaults.summarize_tool_calls;
494
495
  const detailed_tool_recaps = typeof partial.detailed_tool_recaps === 'boolean' ? partial.detailed_tool_recaps : defaults.detailed_tool_recaps;
@@ -500,7 +501,7 @@ function mergeConfig(partial) {
500
501
  const kinds = mergeKinds(partial.kinds);
501
502
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
502
503
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
503
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, html_pages, page_components, whip_messages, mouse_mode_default, live_cycles, condensed_history, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
504
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, html_surface, whip_messages, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
504
505
  }
505
506
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
506
507
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -64,6 +64,9 @@ export interface StdinParam {
64
64
  name: string;
65
65
  required: boolean;
66
66
  constraint: string;
67
+ /** Whether one positional token may supply stdin instead of a pipe. Defaults
68
+ * to true for existing stdin-body leaves; false requires actual stdin. */
69
+ allowPositional?: boolean;
67
70
  }
68
71
  /** --context-file PATH: reads and JSON-parses the file at PATH. */
69
72
  export interface ContextFileParam {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { RICH_PAGE_PLACEHOLDER, renderHtmlProseAsMarkdown } from '../html-markdown.js';
4
+ test('downconverts bounded semantic HTML page prose without raw tag leakage', () => {
5
+ const markdown = renderHtmlProseAsMarkdown(`<!doctype html><html><head><title>ignored</title></head><body>
6
+ <h2>Release <em>notes</em></h2>
7
+ <p>Read <strong>this</strong>, <code>npm run build</code>, and <a href="https://example.test/a">details</a>.</p>
8
+ <ul><li>First</li><li>Second<ul><li>Nested</li></ul></li></ul>
9
+ <ol><li>One</li></ol>
10
+ <blockquote><p>Quoted</p></blockquote>
11
+ <pre><code class="language-ts">const x = 1;</code></pre>
12
+ <table><thead><tr><th>Name</th><th>State</th></tr></thead><tbody><tr><td>A</td><td>Ready</td></tr></tbody></table>
13
+ <p>Safe <span style="color:red">text</span>.</p>
14
+ <div class="custom-chrome">Widget controls</div>
15
+ <crtr-pages><crtr-page><p>Before <span>slot</span>.</p><crtr-options>ignored config</crtr-options><p>After.</p></crtr-page></crtr-pages>
16
+ <p>Use &lt;span&gt;x&lt;/span&gt; literally.</p>
17
+ <p># Not heading<br>&gt; Not quote<br>- Not list<br>1. Not ordered</p>
18
+ </body></html>`);
19
+ const expected = `## Release *notes*
20
+
21
+ Read **this**, \`npm run build\`, and [details](<https://example.test/a>).
22
+
23
+ - First
24
+ - Second
25
+ - Nested
26
+
27
+ 1. One
28
+
29
+ > Quoted
30
+
31
+ \`\`\`ts
32
+ const x = 1;
33
+ \`\`\`
34
+
35
+ | Name | State |
36
+ | --- | --- |
37
+ | A | Ready |
38
+
39
+ Safe text.
40
+
41
+ ${RICH_PAGE_PLACEHOLDER}
42
+
43
+ Before slot.
44
+
45
+ After.
46
+
47
+ Use &lt;span&gt;x&lt;/span&gt; literally.
48
+
49
+ ` + ['\\# Not heading ', '&gt; Not quote ', '\\- Not list ', '1\\. Not ordered'].join('\n');
50
+ assert.equal(markdown, expected);
51
+ assert.doesNotMatch(markdown, /<(?:span|div)\b/i);
52
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { normalizePageComponents, pageComponentKinds } from '../page-catalog.js';
4
+ test('page component registrations accept legacy names and documented objects', () => {
5
+ const components = normalizePageComponents([
6
+ 'approval',
7
+ { kind: 'approval-card', description: 'Approval card', useWhen: 'Use for an approval.', doc: 'Product contract.', display: true },
8
+ ]);
9
+ assert.deepEqual(components, [
10
+ { kind: 'approval' },
11
+ { kind: 'approval-card', description: 'Approval card', useWhen: 'Use for an approval.', doc: 'Product contract.', display: true },
12
+ ]);
13
+ assert.deepEqual(pageComponentKinds(components), ['approval', 'approval-card']);
14
+ assert.throws(() => normalizePageComponents([{ kind: 'approval', display: 'yes' }]), /page_components\[0\]\.display must be a boolean/);
15
+ assert.throws(() => normalizePageComponents([{ kind: 'approval', unexpected: true }]), /unknown field\(s\): unexpected/);
16
+ });
@@ -1,7 +1,5 @@
1
- // Two properties of the default page template whose absence would be SILENT —
2
- // every ordinary page still renders and still looks right when either breaks.
3
- // Nothing else about rendering is pinned here; prose shape is the renderer's
4
- // own business and a change there is visible the moment anyone looks at a page.
1
+ // An authored HTML slot id must reach the runtime's `slot-id` attribute. Without
2
+ // this pin every element renders but no response reaches the host.
5
3
  import assert from 'node:assert/strict';
6
4
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
5
  import { tmpdir } from 'node:os';
@@ -9,41 +7,60 @@ import { join } from 'node:path';
9
7
  import test from 'node:test';
10
8
  import { derivePageManifest } from '../page.js';
11
9
  import { renderPageHtml } from '../page-render.js';
12
- function render(name, source) {
10
+ import { preparePage } from '../tickets.js';
11
+ function render(source) {
13
12
  const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
14
13
  try {
15
- const path = join(dir, name);
14
+ const path = join(dir, 'page.html');
16
15
  writeFileSync(path, source);
17
- return renderPageHtml(derivePageManifest(path, name.endsWith('.md') ? 'md' : 'html'), readFileSync(path, 'utf8'));
16
+ return renderPageHtml(derivePageManifest(path, 'html'), readFileSync(path, 'utf8'));
18
17
  }
19
18
  finally {
20
19
  rmSync(dir, { recursive: true, force: true });
21
20
  }
22
21
  }
23
- test('a config value carrying script markup cannot end its own <script> element', () => {
24
- const html = render('page.md', [
25
- '---', 'title: Escaping', '---', '',
26
- 'Pick one.', '',
27
- '```slot:options',
28
- '{"id":"esc","mode":"single","options":[{"id":"a","label":"</script><script>x</script>"},{"id":"b","label":"<!-- c -->"}]}',
29
- '```', '',
30
- ].join('\n'));
31
- const config = /<crtr-options slot-id="esc"><script type="application\/json">([\s\S]*?)<\/script>/.exec(html)?.[1];
32
- assert.ok(config !== undefined, `no options config in payload:\n${html}`);
33
- // The mutation this catches: dropping the `<` escape for a plain JSON.stringify.
34
- assert.ok(!config.includes('<'), `config text still carries a raw "<": ${config}`);
35
- assert.equal(JSON.parse(config).options[0].label, '</script><script>x</script>');
22
+ test('source-backed slots render embedded config, never a filesystem path', () => {
23
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-source-'));
24
+ try {
25
+ const data = join(dir, 'rows.json');
26
+ const page = join(dir, 'page.html');
27
+ writeFileSync(data, JSON.stringify([{ id: 'one', cells: { name: '</script><script>window.pwned=1</script>' } }]));
28
+ writeFileSync(page, `<!doctype html><html><head><meta name="crtr-page-title" content="Rows"></head><body><crtr-table><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: '</scripture>' }], source: data })}</script></crtr-table></body></html>`);
29
+ const prepared = preparePage({ sourceFile: page, dialect: 'html' });
30
+ const rendered = renderPageHtml(prepared.manifest, prepared.document);
31
+ assert.match(rendered, /"rows":\[{"id":"one","cells":\{"name":"\\u003c\/script>\\u003cscript>window\.pwned=1\\u003c\/script>"}}]/);
32
+ assert.match(rendered, /"label":"\\u003c\/scripture>"/);
33
+ assert.doesNotMatch(rendered, /"source"|rows\.json|<script>window\.pwned/);
34
+ }
35
+ finally {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ });
39
+ test('render replacement ignores comments and non-config scripts', () => {
40
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-scripts-'));
41
+ try {
42
+ const data = join(dir, 'rows.json');
43
+ const page = join(dir, 'page.html');
44
+ writeFileSync(data, JSON.stringify([{ id: 'one', cells: { name: 'One' } }]));
45
+ writeFileSync(page, `<!doctype html><html><head><meta name="crtr-page-title" content="Rows"></head><body><!-- > <crtr-table><script type="application/json">{}</script></crtr-table> --><crtr-table><script data-type="application/json" type="text/plain">KEEP</script><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: 'Name' }], source: data })}</script></crtr-table></body></html>`);
46
+ const prepared = preparePage({ sourceFile: page, dialect: 'html' });
47
+ const rendered = renderPageHtml(prepared.manifest, prepared.document);
48
+ assert.match(rendered, /<script data-type="application\/json" type="text\/plain">KEEP<\/script>/);
49
+ assert.match(rendered, /"rows":\[{"id":"one","cells":\{"name":"One"}}]/);
50
+ assert.doesNotMatch(rendered, /"source"|rows\.json/);
51
+ }
52
+ finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
36
55
  });
37
56
  test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
38
- const html = render('page.html', [
57
+ const html = render([
39
58
  '<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
40
59
  '<crtr-pages><crtr-page label="Window">',
41
60
  '<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
42
61
  '</crtr-page></crtr-pages>',
43
62
  '</body></html>',
44
63
  ].join('\n'));
45
- // Without this, every element mounts and renders and no response ever
46
- // reaches the host, because `src/pages` keys responses by `slot-id`.
47
64
  assert.match(html, /<crtr-options id="window" slot-id="window">/);
48
65
  assert.match(html, /<crtr-page label="Window">/);
49
66
  });
@@ -9,47 +9,64 @@ const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath
9
9
  const { claimTicket } = await import('../claim.js');
10
10
  const { scanInbox, scanPageHistory } = await import('../scan.js');
11
11
  const { ticketDir } = await import('../root.js');
12
- const { cancelTicket, completePage, readPageProgress, readPageTicket, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
12
+ const { cancelTicket, completePage, readPageProgress, readPageTicket, preparePage, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
13
13
  const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
14
14
  function sourceFile(name, body) {
15
15
  const path = join(sources, name);
16
16
  writeFileSync(path, body);
17
17
  return path;
18
18
  }
19
- const askPage = sourceFile('ask.md', `---
20
- title: Rollout scope
21
- subtitle: Pick tonight's rollout
22
- placement: inline
23
- ---
24
-
25
- Which rollout do you want?
26
-
27
- \`\`\`slot:options
28
- {"id": "rollout", "mode": "single", "options": [{"id": "canary", "label": "Canary first"}, {"id": "full", "label": "Full"}]}
29
- \`\`\`
30
- `);
19
+ const askPage = sourceFile('ask.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Rollout scope"><meta name="crtr-page-subtitle" content="Pick tonight’s rollout"><meta name="crtr-page-placement" content="inline"></head><body><p>Which rollout do you want?</p><crtr-pages><crtr-page><crtr-options id="rollout"><script type="application/json">{"mode":"single","options":[{"id":"canary","label":"Canary first"},{"id":"full","label":"Full"}]}</script></crtr-options></crtr-page></crtr-pages></body></html>');
31
20
  const displayPage = sourceFile('display.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Nightly report"></head><body><h1>All green</h1></body></html>');
32
- function submit(id, source, dialect, blockedSince, nodeId = 'node-1') {
33
- return submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
21
+ function submit(id, source, dialect, blockedSince, nodeId = 'node-1', mode = 'ask') {
22
+ const dir = submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
23
+ atomicWriteJson(join(dir, 'run.json'), { mode });
24
+ return dir;
34
25
  }
35
26
  test('submitPage publishes the authored source, the derived manifest and the blockedSince stamp', () => {
36
- const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md', source: { nodeId: 'node-1' } });
27
+ const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html', source: { nodeId: 'node-1' } });
37
28
  assert.equal(submitted.kind, 'page');
38
29
  assert.equal(submitted.id, 'ask');
39
- assert.equal(readFileSync(pagePath(submitted.dir, 'md'), 'utf8'), readFileSync(askPage, 'utf8'));
30
+ assert.equal(readFileSync(pagePath(submitted.dir, 'html'), 'utf8'), readFileSync(askPage, 'utf8'));
40
31
  const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
41
32
  assert.equal(manifest['schema'], 'crtr.page/v1');
42
- assert.equal(manifest['document'], 'page.md');
33
+ assert.equal(manifest['document'], 'page.html');
43
34
  assert.equal(manifest['placement'], 'inline');
44
35
  const source = manifest['source'];
45
36
  assert.equal(source.nodeId, 'node-1');
46
37
  assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
47
- assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md' }), /already exists/);
38
+ assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html' }), /already exists/);
39
+ atomicWriteJson(join(submitted.dir, 'run.json'), { mode: 'ask' });
48
40
  const listed = scanInbox().find((item) => item.id === 'ask');
49
41
  assert.equal(listed?.kind, 'page');
50
42
  assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
51
43
  assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
52
44
  });
45
+ function tableSourcePage(dataFile) {
46
+ return `<!doctype html><html><head><meta name="crtr-page-title" content="Data"><meta name="crtr-page-placement" content="inline"></head><body><crtr-pages><crtr-page><crtr-table id="rows"><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: 'Name' }], source: dataFile, rowSelect: 'single' })}</script></crtr-table></crtr-page></crtr-pages></body></html>`;
47
+ }
48
+ test('source data is embedded into the submitted manifest', () => {
49
+ const data = sourceFile('rows.json', JSON.stringify([{ id: 'one', cells: { name: 'One' } }]));
50
+ const page = sourceFile('source-table.html', tableSourcePage(data));
51
+ const submitted = submitPage({ dir: ticketDir('source-table'), sourceFile: page, dialect: 'html' });
52
+ const config = submitted.manifest.slots[0].config;
53
+ assert.deepEqual(config.rows, [{ id: 'one', cells: { name: 'One' } }]);
54
+ assert.equal('source' in config, false);
55
+ });
56
+ test('invalid source data fails before a ticket is written', () => {
57
+ const cases = [
58
+ ['missing', join(sources, 'missing.json'), /does not exist/],
59
+ ['invalid-json', sourceFile('invalid.json', '{nope'), /not valid JSON/],
60
+ ['invalid-shape', sourceFile('invalid-shape.json', JSON.stringify([{ cells: { name: 'Missing id' } }])), /source data is invalid/],
61
+ ];
62
+ for (const [id, data, expected] of cases) {
63
+ const page = sourceFile(`${id}.html`, tableSourcePage(data));
64
+ const dir = ticketDir(`source-${id}`);
65
+ assert.throws(() => preparePage({ sourceFile: page, dialect: 'html' }), expected);
66
+ assert.throws(() => submitPage({ dir, sourceFile: page, dialect: 'html' }), expected);
67
+ assert.equal(existsSync(dir), false, `${id} source validation writes no ticket`);
68
+ }
69
+ });
53
70
  test('progress round-trips through validation and rejects malformed saved work', () => {
54
71
  const dir = ticketDir('ask');
55
72
  assert.equal(readPageProgress(dir), null, 'no progress file reads as null');
@@ -77,23 +94,27 @@ test('readPageTicket derives pending, resolved and canceled state and carries th
77
94
  assert.equal(written['schema'], 'crtr.page-response/v1');
78
95
  assert.equal(written['kind'], 'page');
79
96
  assert.deepEqual(written['responses'], { rollout: { selectedOptionIds: ['full'], comments: [] } });
80
- assert.equal(written['summary'], 'rollout: Full');
97
+ assert.equal(written['summary'], 'Full');
81
98
  assert.equal(readTicketResult(dir)?.kind, 'page', 'the strict decoder accepts the page result');
82
99
  assert.equal(existsSync(progressPath(dir)), false, 'completion clears owned work');
83
100
  const resolved = readPageTicket(dir);
84
101
  assert.equal(resolved.state, 'resolved');
85
102
  assert.equal(resolved.progress, null);
86
103
  assert.equal(scanInbox().some((item) => item.id === 'ask'), false, 'a resolved page leaves the pending list');
87
- const canceledDir = submit('canceled', askPage, 'md', '2026-01-03T00:00:00.000Z');
104
+ const canceledDir = submit('canceled', askPage, 'html', '2026-01-03T00:00:00.000Z');
88
105
  cancelTicket(canceledDir, { reason: 'no longer needed' });
89
106
  assert.equal(readPageTicket(canceledDir).state, 'canceled');
90
107
  });
91
- test('a display page acknowledges with the empty responses map', () => {
108
+ test('page scan derives response expectation from the ticket mode', () => {
92
109
  const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
93
110
  const ticket = readPageTicket(dir);
94
111
  assert.equal(ticket.manifest.dialect, 'html');
95
112
  assert.equal(ticket.manifest.slots.length, 0);
96
- assert.equal(scanInbox().find((item) => item.id === 'display')?.kind === 'page', true);
113
+ const display = scanInbox().find((item) => item.id === 'display');
114
+ assert.equal(display?.kind === 'page' ? display.awaitsResponse : undefined, true, 'an ask waits even when its page has no response-bearing slots');
115
+ submit('notify-with-options', askPage, 'html', '2026-01-04T00:00:01.000Z', 'node-1', 'notify');
116
+ const notify = scanInbox().find((item) => item.id === 'notify-with-options');
117
+ assert.equal(notify?.kind === 'page' ? notify.awaitsResponse : undefined, false, 'a notify never waits even when its page has a response-bearing slot');
97
118
  const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
98
119
  assert.equal(acknowledged.won, true);
99
120
  const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
@@ -102,11 +123,11 @@ test('a display page acknowledges with the empty responses map', () => {
102
123
  assert.equal(written['summary'], '');
103
124
  });
104
125
  test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
105
- submit('older', askPage, 'md', '2026-01-01T00:00:00.000Z');
106
- submit('other-node', askPage, 'md', '2026-01-02T00:00:00.000Z', 'node-2');
126
+ submit('older', askPage, 'html', '2026-01-01T00:00:00.000Z');
127
+ submit('other-node', askPage, 'html', '2026-01-02T00:00:00.000Z', 'node-2');
107
128
  const history = scanPageHistory('node-1');
108
- assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'ask']);
109
- assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'resolved']);
129
+ assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'notify-with-options', 'ask']);
130
+ assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'pending', 'resolved']);
110
131
  assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
111
132
  assert.deepEqual(scanPageHistory('node-missing'), []);
112
133
  });