@north-light/crouter 0.3.191 → 0.3.193

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 (169) hide show
  1. package/dist/api/client.d.ts +31 -10
  2. package/dist/api/client.js +83 -24
  3. package/dist/api/dto/bash-jobs.d.ts +22 -0
  4. package/dist/api/dto/bash-jobs.js +9 -0
  5. package/dist/api/dto/human.d.ts +3 -4
  6. package/dist/api/dto/inbox.d.ts +106 -65
  7. package/dist/api/dto/inbox.js +2 -8
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +7 -0
  12. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  15. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  17. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  18. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  20. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  21. package/dist/clients/attach/input/overlay-owner.js +1 -1
  22. package/dist/clients/attach/overlays/auth.js +34 -8
  23. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  24. package/dist/clients/attach/overlays/pickers.js +37 -43
  25. package/dist/clients/attach/viewer.js +643 -620
  26. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  27. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  28. package/dist/clients/inbox/controller.d.ts +2 -3
  29. package/dist/clients/inbox/controller.js +41 -42
  30. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  31. package/dist/clients/inbox/page-adapter.js +54 -0
  32. package/dist/clients/inbox/resolve.d.ts +2 -2
  33. package/dist/clients/inbox/resolve.js +1 -1
  34. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  35. package/dist/clients/inbox/review-visibility.js +1 -1
  36. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  37. package/dist/clients/inbox/tui/ansi.js +2 -2
  38. package/dist/clients/inbox/tui/input.d.ts +1 -3
  39. package/dist/clients/inbox/tui/input.js +242 -553
  40. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  41. package/dist/clients/inbox/tui/panel.js +59 -187
  42. package/dist/clients/inbox/tui/render.d.ts +0 -25
  43. package/dist/clients/inbox/tui/render.js +165 -416
  44. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  45. package/dist/clients/inbox/tui/slots.js +156 -0
  46. package/dist/clients/inbox/tui/types.d.ts +25 -13
  47. package/dist/clients/inbox/tui.js +5 -3
  48. package/dist/commands/attention.js +2 -2
  49. package/dist/commands/human/doc.js +66 -20
  50. package/dist/commands/human/prompts.d.ts +3 -3
  51. package/dist/commands/human/prompts.js +158 -112
  52. package/dist/commands/human/queue.d.ts +0 -1
  53. package/dist/commands/human/queue.js +34 -94
  54. package/dist/commands/human/shared.d.ts +10 -4
  55. package/dist/commands/human/shared.js +62 -13
  56. package/dist/commands/human.js +8 -4
  57. package/dist/commands/memory/read.js +13 -5
  58. package/dist/commands/node/bash.js +15 -28
  59. package/dist/commands/surface/node/focus.js +1 -1
  60. package/dist/core/__tests__/human-deliver.test.js +40 -46
  61. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  62. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  63. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  64. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  65. package/dist/core/auth-interaction.d.ts +2 -2
  66. package/dist/core/bash-jobs.d.ts +32 -0
  67. package/dist/core/bash-jobs.js +72 -1
  68. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  69. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  70. package/dist/core/canvas/attention.js +1 -1
  71. package/dist/core/canvas/browse/app.js +1 -1
  72. package/dist/core/canvas/nav-model.d.ts +1 -1
  73. package/dist/core/canvas/nav-model.js +1 -1
  74. package/dist/core/canvas/remote-canvas-source.js +1 -1
  75. package/dist/core/config.js +4 -1
  76. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  77. package/dist/core/human/__tests__/page-render.test.js +49 -0
  78. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  79. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  80. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  81. package/dist/core/human/__tests__/page.test.js +139 -0
  82. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  83. package/dist/core/human/claim.js +2 -2
  84. package/dist/core/human/convention.d.ts +4 -4
  85. package/dist/core/human/convention.js +6 -13
  86. package/dist/core/human/markdown-html.d.ts +8 -0
  87. package/dist/core/human/markdown-html.js +581 -0
  88. package/dist/core/human/page-catalog.d.ts +6 -0
  89. package/dist/core/human/page-catalog.js +43 -0
  90. package/dist/core/human/page-render.d.ts +3 -0
  91. package/dist/core/human/page-render.js +131 -0
  92. package/dist/core/human/page-schema.d.ts +312 -0
  93. package/dist/core/human/page-schema.js +291 -0
  94. package/dist/core/human/page-synth.d.ts +9 -0
  95. package/dist/core/human/page-synth.js +17 -0
  96. package/dist/core/human/page.d.ts +22 -0
  97. package/dist/core/human/page.js +427 -0
  98. package/dist/core/human/review-schema.d.ts +19 -0
  99. package/dist/core/human/review-schema.js +34 -0
  100. package/dist/core/human/scan.d.ts +12 -3
  101. package/dist/core/human/scan.js +69 -26
  102. package/dist/core/human/summary.d.ts +5 -6
  103. package/dist/core/human/summary.js +54 -37
  104. package/dist/core/human/tickets.d.ts +61 -11
  105. package/dist/core/human/tickets.js +100 -57
  106. package/dist/core/human/types.d.ts +21 -66
  107. package/dist/core/human/types.js +1 -5
  108. package/dist/core/preview-registry.js +0 -1
  109. package/dist/core/review/ticket-filter.d.ts +1 -1
  110. package/dist/core/review/ticket-filter.js +2 -8
  111. package/dist/core/runtime/broker/read-ops.js +3 -0
  112. package/dist/core/runtime/broker.js +19 -10
  113. package/dist/core/runtime/package-health.js +1 -1
  114. package/dist/core/termrender/termrender.d.ts +1 -1
  115. package/dist/core/termrender/termrender.js +2 -2
  116. package/dist/core/tui/page-host.d.ts +1 -1
  117. package/dist/core/tui/page-host.js +2 -2
  118. package/dist/core/user-settings.d.ts +19 -0
  119. package/dist/core/user-settings.js +22 -0
  120. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  121. package/dist/daemon/api/handlers/bash-jobs.js +89 -0
  122. package/dist/daemon/api/handlers/human.js +16 -14
  123. package/dist/daemon/api/handlers/inbox.js +311 -227
  124. package/dist/daemon/api/router.d.ts +5 -1
  125. package/dist/daemon/api/router.js +5 -0
  126. package/dist/daemon/api/server.js +2 -0
  127. package/dist/daemon/cron-run.js +23 -10
  128. package/dist/daemon/human/finish.d.ts +8 -3
  129. package/dist/daemon/human/finish.js +35 -15
  130. package/dist/pages/bundle.css +1 -0
  131. package/dist/pages/bundle.js +1347 -0
  132. package/dist/pages/comments.d.ts +34 -0
  133. package/dist/pages/comments.js +72 -0
  134. package/dist/pages/elements/cards.d.ts +17 -0
  135. package/dist/pages/elements/cards.js +785 -0
  136. package/dist/pages/elements/chart.d.ts +1 -0
  137. package/dist/pages/elements/chart.js +648 -0
  138. package/dist/pages/elements/options.d.ts +21 -0
  139. package/dist/pages/elements/options.js +604 -0
  140. package/dist/pages/elements/pages.d.ts +24 -0
  141. package/dist/pages/elements/pages.js +355 -0
  142. package/dist/pages/elements/slot.d.ts +21 -0
  143. package/dist/pages/elements/slot.js +105 -0
  144. package/dist/pages/elements/table.d.ts +19 -0
  145. package/dist/pages/elements/table.js +906 -0
  146. package/dist/pages/elements/text.d.ts +25 -0
  147. package/dist/pages/elements/text.js +951 -0
  148. package/dist/pages/entry.d.ts +8 -0
  149. package/dist/pages/entry.js +8 -0
  150. package/dist/pages/host.d.ts +132 -0
  151. package/dist/pages/host.js +149 -0
  152. package/dist/pages/register.d.ts +2 -0
  153. package/dist/pages/register.js +3 -0
  154. package/dist/pages/slot-config.d.ts +36 -0
  155. package/dist/pages/slot-config.js +50 -0
  156. package/dist/pages/types.d.ts +98 -0
  157. package/dist/pages/types.js +23 -0
  158. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +13 -5
  159. package/dist/pi-extensions/canvas-structured-output.js +7 -3
  160. package/dist/types.d.ts +4 -0
  161. package/dist/types.js +2 -0
  162. package/package.json +8 -8
  163. package/runtime.lock.json +156 -101
  164. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  165. package/dist/clients/inbox/deck-adapter.js +0 -63
  166. package/dist/core/human/deck-factories.d.ts +0 -8
  167. package/dist/core/human/deck-factories.js +0 -15
  168. package/dist/core/human/deck-schema.d.ts +0 -70
  169. package/dist/core/human/deck-schema.js +0 -92
@@ -14,10 +14,42 @@ export interface BashJobPaths {
14
14
  jobPgid: string;
15
15
  }
16
16
  export declare function bashJobsDir(contextDir: string): string;
17
+ /** Whether a job id can safely be used as one path segment. Generated ids are
18
+ * numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
19
+ * older file-backed ids readable without allowing path or request delimiters. */
20
+ export declare function isSafeBashJobId(jobId: string): boolean;
17
21
  export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
18
22
  /** The job's supervisor process group, or undefined for a job started before
19
23
  * pgid was persisted (or a partially-written dir). */
20
24
  export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
25
+ /** Whether a persisted job supervisor process group is still present. EPERM
26
+ * means the group exists but cannot be signaled by this process; it is still
27
+ * live for the status surface. */
28
+ export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
29
+ export type StopBackgroundBashJobResult = {
30
+ kind: 'stopped';
31
+ signaled: boolean;
32
+ pgid: number;
33
+ logPath: string;
34
+ } | {
35
+ kind: 'missing';
36
+ } | {
37
+ kind: 'retired';
38
+ logPath: string;
39
+ } | {
40
+ kind: 'not-stoppable';
41
+ logPath: string;
42
+ };
43
+ /** Stop one live background job using the same process-group and sentinel
44
+ * semantics as `crtr node bash kill`. The caller owns the user-facing
45
+ * notification; this primitive owns only the job control-plane mutation.
46
+ *
47
+ * Only a job in the live-background state the roster projects (job.bg
48
+ * present, job.exit absent) may be signaled. Job directories persist after
49
+ * the supervisor exits, so a retired, still-foreground, or already-stopped
50
+ * job would otherwise have its recorded pgid signaled long after the OS
51
+ * could have reused that id for an unrelated process group. */
52
+ export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
21
53
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
22
54
  * rather than the whole file, which for a long-running job can be enormous. */
23
55
  export declare function tailJobLog(logPath: string, count: number): string[];
@@ -3,11 +3,20 @@
3
3
  // job.bg, while foreground completion renames it to job.done. The detached
4
4
  // supervisor can therefore outlive pi and still decide whether to report exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  export function bashJobsDir(contextDir) {
9
9
  return join(contextDir, 'jobs');
10
10
  }
11
+ /** Whether a job id can safely be used as one path segment. Generated ids are
12
+ * numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
13
+ * older file-backed ids readable without allowing path or request delimiters. */
14
+ export function isSafeBashJobId(jobId) {
15
+ return typeof jobId === 'string'
16
+ && jobId !== '' && jobId !== '.' && jobId !== '..'
17
+ && /^[A-Za-z0-9._~-]+$/u.test(jobId)
18
+ && Buffer.byteLength(jobId, 'utf8') <= 128;
19
+ }
11
20
  export function bashJobPaths(contextDir, jobId) {
12
21
  const dir = join(bashJobsDir(contextDir), jobId);
13
22
  return {
@@ -33,6 +42,68 @@ export function readJobPgid(contextDir, jobId) {
33
42
  return undefined;
34
43
  }
35
44
  }
45
+ /** Whether a persisted job supervisor process group is still present. EPERM
46
+ * means the group exists but cannot be signaled by this process; it is still
47
+ * live for the status surface. */
48
+ export function isBashJobProcessGroupAlive(pgid) {
49
+ try {
50
+ process.kill(-pgid, 0);
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ return err.code === 'EPERM';
55
+ }
56
+ }
57
+ /** Stop one live background job using the same process-group and sentinel
58
+ * semantics as `crtr node bash kill`. The caller owns the user-facing
59
+ * notification; this primitive owns only the job control-plane mutation.
60
+ *
61
+ * Only a job in the live-background state the roster projects (job.bg
62
+ * present, job.exit absent) may be signaled. Job directories persist after
63
+ * the supervisor exits, so a retired, still-foreground, or already-stopped
64
+ * job would otherwise have its recorded pgid signaled long after the OS
65
+ * could have reused that id for an unrelated process group. */
66
+ export async function stopBackgroundBashJob(contextDir, jobId) {
67
+ const paths = bashJobPaths(contextDir, jobId);
68
+ if (!existsSync(paths.dir))
69
+ return { kind: 'missing' };
70
+ if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
71
+ return { kind: 'retired', logPath: paths.jobLog };
72
+ const pgid = readJobPgid(contextDir, jobId);
73
+ if (pgid === undefined)
74
+ return { kind: 'not-stoppable', logPath: paths.jobLog };
75
+ // Claim the stop by creating job.exit exclusively, before signaling. The
76
+ // loser of two concurrent stops — and a stop that races the supervisor's own
77
+ // completion — reports retired instead of signaling a departed group and
78
+ // sending the owning node a second "was stopped" notice.
79
+ try {
80
+ const fd = openSync(paths.jobExit, 'wx');
81
+ try {
82
+ writeFileSync(fd, '143\n');
83
+ }
84
+ finally {
85
+ closeSync(fd);
86
+ }
87
+ }
88
+ catch (err) {
89
+ if (err.code === 'EEXIST')
90
+ return { kind: 'retired', logPath: paths.jobLog };
91
+ throw err;
92
+ }
93
+ let signaled = true;
94
+ try {
95
+ process.kill(-pgid, 'SIGTERM');
96
+ }
97
+ catch {
98
+ signaled = false;
99
+ }
100
+ await new Promise((resolve) => setTimeout(resolve, 400));
101
+ try {
102
+ process.kill(-pgid, 'SIGKILL');
103
+ }
104
+ catch { /* already gone */ }
105
+ return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
106
+ }
36
107
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
37
108
  * rather than the whole file, which for a long-running job can be enormous. */
38
109
  export function tailJobLog(logPath, count) {
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
4
4
  import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
- import { atomicWriteJson, deckPath } from '../../human/convention.js';
7
+ import { atomicWriteJson, pageManifestPath } from '../../human/convention.js';
8
8
  import { createNode } from '../canvas.js';
9
9
  import { closeDb } from '../db.js';
10
10
  import { nodeDir } from '../paths.js';
@@ -22,12 +22,18 @@ function meta(id, cwd, extra) {
22
22
  ...extra,
23
23
  };
24
24
  }
25
- function seedDeck(bridgeId) {
25
+ function seedPage(bridgeId) {
26
26
  const dir = nodeDir(bridgeId);
27
27
  mkdirSync(dir, { recursive: true });
28
- atomicWriteJson(deckPath(dir), {
28
+ atomicWriteJson(pageManifestPath(dir), {
29
+ schema: 'crtr.page/v1',
30
+ dialect: 'md',
29
31
  title: bridgeId,
30
- interactions: [{ id: 'q', title: bridgeId, subtitle: 'Acknowledge this ticket so the request can proceed.', options: [] }],
32
+ subtitle: 'Acknowledge this ticket so the request can proceed.',
33
+ placement: 'inline',
34
+ document: 'page.md',
35
+ steps: 1,
36
+ slots: [],
31
37
  });
32
38
  }
33
39
  function bridge(id, parent, cwd) {
@@ -61,10 +67,10 @@ test('ticketCountsForNodes follows bridge parents even when asking nodes share a
61
67
  bridge('ha2', 'a', sharedCwd);
62
68
  bridge('hb', 'b', sharedCwd);
63
69
  bridge('hc', 'c', soloCwd);
64
- seedDeck('ha1');
65
- seedDeck('ha2');
66
- seedDeck('hb');
67
- seedDeck('hc');
70
+ seedPage('ha1');
71
+ seedPage('ha2');
72
+ seedPage('hb');
73
+ seedPage('hc');
68
74
  assert.deepEqual(ticketCountsForNodes(['a', 'b', 'c']), { a: 2, b: 1, c: 1 });
69
75
  });
70
76
  test('pendingTicketsForView returns the asking node rather than a cwd bucket', () => {
@@ -72,8 +78,8 @@ test('pendingTicketsForView returns the asking node rather than a cwd bucket', (
72
78
  createNode(meta('b', sharedCwd));
73
79
  bridge('ha', 'a', sharedCwd);
74
80
  bridge('hb', 'b', sharedCwd);
75
- seedDeck('ha');
76
- seedDeck('hb');
81
+ seedPage('ha');
82
+ seedPage('hb');
77
83
  assert.deepEqual(pendingTicketsForView('a').map(({ node_id, count }) => ({ node_id, count })), [
78
84
  { node_id: 'a', count: 1 },
79
85
  ]);
@@ -19,7 +19,7 @@ import { createServer } from 'node:http';
19
19
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { join } from 'node:path';
22
- import { atomicWriteJson, deckPath } from '#core/human/convention';
22
+ import { atomicWriteJson, pageManifestPath } from '#core/human/convention';
23
23
  import { RemoteCanvasSource } from '../remote-canvas-source.js';
24
24
  import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
25
25
  import { createNode } from '../canvas.js';
@@ -113,9 +113,15 @@ function seedPoisonedLocalState() {
113
113
  createNode(meta('a', { cwd: localCwd }));
114
114
  const askDir = nodeDir('poison-ask');
115
115
  mkdirSync(askDir, { recursive: true });
116
- atomicWriteJson(deckPath(askDir), {
116
+ atomicWriteJson(pageManifestPath(askDir), {
117
+ schema: 'crtr.page/v1',
118
+ dialect: 'md',
117
119
  title: 'poison',
118
- interactions: [{ id: 'q', kind: 'ask', title: 'poison', subtitle: 'This local ticket must not affect the remote rendering.' }],
120
+ subtitle: 'This local ticket must not affect the remote rendering.',
121
+ placement: 'inline',
122
+ document: 'page.md',
123
+ steps: 1,
124
+ slots: [],
119
125
  });
120
126
  // A local focus + broker viewer — poison both `viewed` signals.
121
127
  openFocusRow('poison-focus', null, null, 'a');
@@ -3,7 +3,7 @@
3
3
  // Every ticket is a direct child of the single registered `<canvas>/nodes` root,
4
4
  // named for its terminal human bridge node. The bridge's parent is the asking
5
5
  // node, so storage identity and attribution come from the canvas graph rather
6
- // than duplicated attribution fields in deck/review data.
6
+ // than duplicated attribution fields in page/review data.
7
7
  import { basename } from 'node:path';
8
8
  import { scanInbox } from '../human/scan.js';
9
9
  import { filterTerminalReviewTickets } from '../review/ticket-filter.js';
@@ -55,7 +55,7 @@ export async function runBrowse(opts) {
55
55
  process.stdout.write(await renderForestFromSource(source) + '\n');
56
56
  return;
57
57
  }
58
- // Snapshot the canvas. Drop kind:'human' control-plane decks — they have no pi
58
+ // Snapshot the canvas. Drop kind:'human' control-plane pages — they have no pi
59
59
  // session, so `surface node focus` refuses them; they are never a navigation/resume
60
60
  // target (mirrors the surface-node focus guard).
61
61
  let rows;
@@ -3,7 +3,7 @@ import type { NodeMeta } from './index.js';
3
3
  export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, type Telemetry, type FlatRow, type FoldState, type SubtreeActivity, } from './nav-render.js';
4
4
  /** First manager (by created) — the UP step for the ancestry spine. */
5
5
  export declare function managerOf(id: string): string | undefined;
6
- /** A kind:'human' node is a control-plane ASK (a deck on the human's
6
+ /** A kind:'human' node is a control-plane ASK (a page on the human's
7
7
  * screen), NOT a pi conversation — it has no session, so focusing/reviving it
8
8
  * boots a confused blank "you have been revived" pi. Its pending-ask signal
9
9
  * already rides the ⚑ badge on the ASKING node (attention.ts follows the
@@ -73,7 +73,7 @@ function mgrsOf(id) {
73
73
  export function managerOf(id) {
74
74
  return mgrsOf(id)[0]?.node_id;
75
75
  }
76
- /** A kind:'human' node is a control-plane ASK (a deck on the human's
76
+ /** A kind:'human' node is a control-plane ASK (a page on the human's
77
77
  * screen), NOT a pi conversation — it has no session, so focusing/reviving it
78
78
  * boots a confused blank "you have been revived" pi. Its pending-ask signal
79
79
  * already rides the ⚑ badge on the ASKING node (attention.ts follows the
@@ -208,7 +208,7 @@ export class RemoteCanvasSource {
208
208
  }
209
209
  async ticketCountsForView(root) {
210
210
  // The `canvas attention map` transport is unchanged; its `counts` values now
211
- // mean all pending human tickets (deck/review/notify), not just asks.
211
+ // mean all pending human tickets (page/review), not just asks.
212
212
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
213
213
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
214
214
  }
@@ -10,6 +10,7 @@ import { profileRoot } from './profiles/manifest.js';
10
10
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
11
11
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
12
12
  import { normalizeWorkingGerunds } from '../shared/working-activity.js';
13
+ import { normalizePageComponents } from './human/page-catalog.js';
13
14
  function configPathFor(root) {
14
15
  return join(root, CONFIG_FILE);
15
16
  }
@@ -478,6 +479,8 @@ function mergeConfig(partial) {
478
479
  ? [...defaults.working_gerunds]
479
480
  : normalizeWorkingGerunds(partial.working_gerunds);
480
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
+ const page_components = normalizePageComponents(partial.page_components);
481
484
  const whip_messages = normalizeWhipMessages(partial.whip_messages);
482
485
  const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
483
486
  ? partial.mouse_mode_default
@@ -497,7 +500,7 @@ function mergeConfig(partial) {
497
500
  const kinds = mergeKinds(partial.kinds);
498
501
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
499
502
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
500
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, 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 };
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 };
501
504
  }
502
505
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
503
506
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
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.
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import test from 'node:test';
10
+ import { derivePageManifest } from '../page.js';
11
+ import { renderPageHtml } from '../page-render.js';
12
+ function render(name, source) {
13
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
14
+ try {
15
+ const path = join(dir, name);
16
+ writeFileSync(path, source);
17
+ return renderPageHtml(derivePageManifest(path, name.endsWith('.md') ? 'md' : 'html'), readFileSync(path, 'utf8'));
18
+ }
19
+ finally {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ }
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>');
36
+ });
37
+ test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
38
+ const html = render('page.html', [
39
+ '<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
40
+ '<crtr-pages><crtr-page label="Window">',
41
+ '<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
42
+ '</crtr-page></crtr-pages>',
43
+ '</body></html>',
44
+ ].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
+ assert.match(html, /<crtr-options id="window" slot-id="window">/);
48
+ assert.match(html, /<crtr-page label="Window">/);
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ const home = mkdtempSync(join(tmpdir(), 'crtr-page-tickets-'));
7
+ process.env['CRTR_HOME'] = home;
8
+ const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath } = await import('../convention.js');
9
+ const { claimTicket } = await import('../claim.js');
10
+ const { scanInbox, scanPageHistory } = await import('../scan.js');
11
+ const { ticketDir } = await import('../root.js');
12
+ const { cancelTicket, completePage, readPageProgress, readPageTicket, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
13
+ const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
14
+ function sourceFile(name, body) {
15
+ const path = join(sources, name);
16
+ writeFileSync(path, body);
17
+ return path;
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
+ `);
31
+ 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;
34
+ }
35
+ 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' } });
37
+ assert.equal(submitted.kind, 'page');
38
+ assert.equal(submitted.id, 'ask');
39
+ assert.equal(readFileSync(pagePath(submitted.dir, 'md'), 'utf8'), readFileSync(askPage, 'utf8'));
40
+ const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
41
+ assert.equal(manifest['schema'], 'crtr.page/v1');
42
+ assert.equal(manifest['document'], 'page.md');
43
+ assert.equal(manifest['placement'], 'inline');
44
+ const source = manifest['source'];
45
+ assert.equal(source.nodeId, 'node-1');
46
+ assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
47
+ assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'md' }), /already exists/);
48
+ const listed = scanInbox().find((item) => item.id === 'ask');
49
+ assert.equal(listed?.kind, 'page');
50
+ assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
51
+ assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
52
+ });
53
+ test('progress round-trips through validation and rejects malformed saved work', () => {
54
+ const dir = ticketDir('ask');
55
+ assert.equal(readPageProgress(dir), null, 'no progress file reads as null');
56
+ writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
57
+ assert.deepEqual(readPageProgress(dir), { rollout: { selectedOptionIds: ['canary'], comments: [] } });
58
+ assert.throws(() => writePageProgress(dir, { rollout: { selectedOptionIds: ['nope'], comments: [] } }), /nope/);
59
+ assert.throws(() => writePageProgress(dir, { unknown: { selectedOptionIds: [], comments: [] } }), /unknown/);
60
+ atomicWriteJson(progressPath(dir), { kind: 'page', responses: { rollout: 'not an object' }, savedAt: new Date().toISOString() });
61
+ assert.throws(() => readPageProgress(dir), /malformed/, 'malformed progress is an error, never silently discarded');
62
+ writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
63
+ });
64
+ test('readPageTicket derives pending, resolved and canceled state and carries the document', () => {
65
+ const dir = ticketDir('ask');
66
+ const pending = readPageTicket(dir);
67
+ assert.equal(pending.state, 'pending');
68
+ assert.equal(pending.manifest.title, 'Rollout scope');
69
+ assert.ok(pending.document.includes('Which rollout do you want?'));
70
+ assert.deepEqual(pending.progress, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
71
+ const claim = claimTicket(dir);
72
+ assert.notEqual(claim, null, 'a page ticket is claimable');
73
+ assert.throws(() => completePage(dir, { rollout: { selectedOptionIds: [], comments: [] } }, claim.token), /rollout/, 'single-select requires one selection');
74
+ const completed = completePage(dir, { rollout: { selectedOptionIds: ['full'], comments: [] }, }, claim.token);
75
+ assert.equal(completed.won, true);
76
+ const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
77
+ assert.equal(written['schema'], 'crtr.page-response/v1');
78
+ assert.equal(written['kind'], 'page');
79
+ assert.deepEqual(written['responses'], { rollout: { selectedOptionIds: ['full'], comments: [] } });
80
+ assert.equal(written['summary'], 'rollout: Full');
81
+ assert.equal(readTicketResult(dir)?.kind, 'page', 'the strict decoder accepts the page result');
82
+ assert.equal(existsSync(progressPath(dir)), false, 'completion clears owned work');
83
+ const resolved = readPageTicket(dir);
84
+ assert.equal(resolved.state, 'resolved');
85
+ assert.equal(resolved.progress, null);
86
+ 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');
88
+ cancelTicket(canceledDir, { reason: 'no longer needed' });
89
+ assert.equal(readPageTicket(canceledDir).state, 'canceled');
90
+ });
91
+ test('a display page acknowledges with the empty responses map', () => {
92
+ const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
93
+ const ticket = readPageTicket(dir);
94
+ assert.equal(ticket.manifest.dialect, 'html');
95
+ assert.equal(ticket.manifest.slots.length, 0);
96
+ assert.equal(scanInbox().find((item) => item.id === 'display')?.kind === 'page', true);
97
+ const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
98
+ assert.equal(acknowledged.won, true);
99
+ const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
100
+ assert.equal(written['schema'], 'crtr.page-response/v1');
101
+ assert.deepEqual(written['responses'], {});
102
+ assert.equal(written['summary'], '');
103
+ });
104
+ 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');
107
+ 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']);
110
+ assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
111
+ assert.deepEqual(scanPageHistory('node-missing'), []);
112
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,139 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { derivePageManifest, parsePageSource } from '../page.js';
7
+ import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from '../page-schema.js';
8
+ function withSource(name, source, run) {
9
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-test-'));
10
+ try {
11
+ const path = join(dir, name);
12
+ writeFileSync(path, source);
13
+ run(path);
14
+ }
15
+ finally {
16
+ rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ }
19
+ const htmlHead = '<!doctype html><html><head><meta name="crtr-page-title" content="Test"></head><body>';
20
+ const htmlTail = '</body></html>';
21
+ const optionsScript = '<script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"}]}</script>';
22
+ function deriveHtml(body, head = htmlHead) {
23
+ let manifest;
24
+ withSource('input.html', `${head}${body}${htmlTail}`, (path) => { manifest = derivePageManifest(path, 'html'); });
25
+ return manifest;
26
+ }
27
+ function expectHtmlError(body, pattern, head = htmlHead) {
28
+ withSource('input.html', `${head}${body}${htmlTail}`, (path) => assert.throws(() => derivePageManifest(path, 'html'), pattern));
29
+ }
30
+ test('md dialect derives a normalized manifest and preserves top-level slot scanning', () => {
31
+ const source = `---
32
+ title: Rollout
33
+ subtitle: Choose carefully
34
+ placement: inline
35
+ ---
36
+
37
+ Before.
38
+
39
+ \`\`\`\`markdown
40
+ \`\`\`slot:text
41
+ {"id":"nested","initialText":"not a slot"}
42
+ \`\`\`
43
+ \`\`\`\`
44
+
45
+ \`\`\`slot:table
46
+ {"columns":[{"id":"day","label":"Day"}],"rows":[{"id":"r1","cells":{"day":"Mon"}}]}
47
+ \`\`\`
48
+
49
+ \`\`\`slot:options
50
+ {"id":"rollout","mode":"single","options":[{"id":"yes","label":"Yes"}]}
51
+ \`\`\`
52
+ `;
53
+ withSource('input.md', source, (path) => {
54
+ const manifest = derivePageManifest(path, 'md');
55
+ // A display-only table's schema defaults must survive the manifest→source round trip.
56
+ assert.deepEqual(parsePageSource(source, manifest).at(-1), { kind: 'slot', slotIndex: 1 });
57
+ assert.equal(manifest.title, 'Rollout');
58
+ assert.equal(manifest.subtitle, 'Choose carefully');
59
+ assert.equal(manifest.placement, 'inline');
60
+ assert.equal(manifest.document, 'page.md');
61
+ assert.equal(manifest.steps, 1);
62
+ assert.deepEqual(manifest.slots.map(({ id, kind, step }) => ({ id, kind, step })), [{ id: undefined, kind: 'table', step: 0 }, { id: 'rollout', kind: 'options', step: 0 }]);
63
+ });
64
+ });
65
+ test('html dialect derives document-order slots and wrapper steps', () => {
66
+ const body = `<crtr-pages><crtr-page><section><crtr-options id="choice">${optionsScript}</crtr-options></section></crtr-page><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages>`;
67
+ const manifest = deriveHtml(body, '<!doctype html><html placement="inline"><head><meta name="crtr-page-title" content="HTML"><meta name="crtr-page-subtitle" content="Two steps"><meta name="crtr-page-placement" content="inline"></head><body>');
68
+ assert.equal(manifest.title, 'HTML');
69
+ assert.equal(manifest.placement, 'inline');
70
+ assert.equal(manifest.steps, 2);
71
+ assert.deepEqual(manifest.slots.map(({ kind, step }) => ({ kind, step })), [{ kind: 'options', step: 0 }, { kind: 'chart', step: 1 }]);
72
+ });
73
+ test('html wrapper rejects multiple crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page></crtr-page></crtr-pages><crtr-pages><crtr-page></crtr-page></crtr-pages>', /more than one <crtr-pages>/));
74
+ test('html wrapper rejects crtr-page outside crtr-pages', () => expectHtmlError('<crtr-page></crtr-page>', /<crtr-page> outside <crtr-pages>/));
75
+ test('html wrapper rejects empty crtr-pages', () => expectHtmlError('<crtr-pages></crtr-pages>', /empty <crtr-pages>/));
76
+ test('html wrapper rejects a response-bearing element outside a step with the fix', () => expectHtmlError(`<crtr-options id="choice">${optionsScript}</crtr-options>`, /response-bearing element <crtr-options>[\s\S]*<crtr-pages><crtr-page>…<\/crtr-page><\/crtr-pages>/));
77
+ test('html wrapper rejects nested crtr-pages', () => expectHtmlError('<crtr-pages><crtr-page><crtr-pages><crtr-page></crtr-page></crtr-pages></crtr-page></crtr-pages>', /nested <crtr-pages>/));
78
+ test('md front matter names unknown, empty-title, and placement errors', () => {
79
+ for (const [source, pattern] of [
80
+ ['---\ntitle: T\nother: no\n---\n', /unknown page front matter key "other"/],
81
+ ['---\ntitle: \n---\n', /title is required and must be nonempty/],
82
+ ['---\ntitle: T\nplacement: sidebar\n---\n', /placement must be exactly inline or panel/],
83
+ ])
84
+ withSource('input.md', source, (path) => assert.throws(() => derivePageManifest(path, 'md'), pattern));
85
+ });
86
+ test('html metadata enforces cardinality, known names, and placement content', () => {
87
+ const duplicateTitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-title" content="B"></head><body>';
88
+ const duplicateSubtitle = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-subtitle" content="A"><meta name="crtr-page-subtitle" content="B"></head><body>';
89
+ const unknown = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-extra" content="x"></head><body>';
90
+ const badPlacement = '<!doctype html><html><head><meta name="crtr-page-title" content="A"><meta name="crtr-page-placement" content="drawer"></head><body>';
91
+ expectHtmlError('', /Expected exactly one <meta name="crtr-page-title">, found 2/, duplicateTitle);
92
+ expectHtmlError('', /Expected at most one <meta name="crtr-page-subtitle">, found 2/, duplicateSubtitle);
93
+ expectHtmlError('', /unknown page metadata element <meta name="crtr-page-extra">/, unknown);
94
+ expectHtmlError('', /content must be exactly inline or panel/, badPlacement);
95
+ });
96
+ test('the response-bearing predicate handles table selection and catalog edges', () => {
97
+ const slot = (kind, config, unvalidated) => ({ kind, step: 0, config, ...(unvalidated ? { unvalidated } : {}) });
98
+ assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'none', columnSelect: 'none' })), false);
99
+ assert.equal(isResponseBearingSlot(slot('table', { columns: [{ id: 'c', label: 'C' }], rowSelect: 'single', columnSelect: 'none' })), true);
100
+ assert.equal(isResponseBearingSlot(slot('chart', { kind: 'bar', x: 'x', y: [{ field: 'y' }] })), false);
101
+ assert.equal(isResponseBearingSlot(slot('product-kind', {}, true)), true);
102
+ });
103
+ test('final and partial response validation share slot-addressed rules', () => {
104
+ const manifest = deriveHtml(`<crtr-pages><crtr-page><crtr-options id="choice">${optionsScript}</crtr-options><crtr-text id="note"><script type="application/json">{"initialText":""}</script></crtr-text></crtr-page></crtr-pages>`);
105
+ assert.deepEqual(validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] } });
106
+ assert.throws(() => validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] } }), /missing response-bearing slot id "note"/);
107
+ assert.deepEqual(validatePageResponses(manifest, { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } }), { choice: { selectedOptionIds: ['yes'], comments: [] }, note: { text: '', edited: false, comments: [] } });
108
+ assert.throws(() => validatePartialPageResponses(manifest, { choice: { selectedOptionIds: ['maybe'], comments: [] } }), /unknown option id "maybe"/);
109
+ assert.throws(() => validatePartialPageResponses(manifest, { extra: {} }), /unknown or non-response-bearing slot id "extra"/);
110
+ });
111
+ test('PageSourceProjection associates leading, interstitial, and trailing prose', () => {
112
+ const source = `---
113
+ title: Projection
114
+ ---
115
+ Leading prose.
116
+
117
+ \`\`\`slot:text
118
+ {"id":"first","initialText":""}
119
+ \`\`\`
120
+
121
+ Interstitial prose.
122
+
123
+ \`\`\`slot:text
124
+ {"id":"second","initialText":""}
125
+ \`\`\`
126
+
127
+ Trailing prose.
128
+ `;
129
+ withSource('input.md', source, (path) => {
130
+ const manifest = derivePageManifest(path, 'md');
131
+ assert.deepEqual(parsePageSource(source, manifest), [
132
+ { kind: 'prose', markdown: 'Leading prose.' },
133
+ { kind: 'slot', slotIndex: 0 },
134
+ { kind: 'prose', markdown: 'Interstitial prose.' },
135
+ { kind: 'slot', slotIndex: 1 },
136
+ { kind: 'prose', markdown: 'Trailing prose.' },
137
+ ]);
138
+ });
139
+ });