@north-light/crouter 0.3.192 → 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 (167) 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/types.d.ts +4 -0
  159. package/dist/types.js +2 -0
  160. package/package.json +8 -8
  161. package/runtime.lock.json +156 -101
  162. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  163. package/dist/clients/inbox/deck-adapter.js +0 -63
  164. package/dist/core/human/deck-factories.d.ts +0 -8
  165. package/dist/core/human/deck-factories.js +0 -15
  166. package/dist/core/human/deck-schema.d.ts +0 -70
  167. package/dist/core/human/deck-schema.js +0 -92
@@ -1,17 +1,66 @@
1
1
  import { readConfig } from '../../core/config.js';
2
- export const BODY_PATH_CONTRACT = 'bodyPath is a relative path to the body file, resolved from the directory containing the deck JSON; ' +
3
- 'it must stay inside that directory, and paths escaping with .. or symlinks are rejected; absolute paths are unsupported';
4
- export const DECK_SCHEMA_HINT = 'Deck must match the deck schema: {title (required short inbox topic), ' +
5
- 'source?:{sessionName?,askedBy?,blockedSince?}, interactions:[{id, ' +
6
- 'title (short ≤~4-word inbox topic that identifies the decision for a cold reader), ' +
7
- 'subtitle (required ONE plain-English sentence stating the decision, your recommendation, and its stakes), ' +
8
- `(body?|bodyPath?) (a self-contained decision brief: give the minimum plain-language ` +
9
- `context and tradeoffs needed to decide, then place supporting evidence, chronology, and ` +
10
- `technical detail under ## Details; the ONLY place for long or rich prose — directive-flavored ` +
11
- `markdown rendered by termrender). ${BODY_PATH_CONTRACT}. ` +
12
- 'body and bodyPath are mutually exclusive, options:[{id,label,description?}], multiSelect?, ' +
13
- 'allowFreetext?, freetextLabel?, ' +
14
- "kind?:'notify'|'decision'|'context'|'error'}]}.";
2
+ function markdownAuthoring(productKinds) {
3
+ const productSlots = productKinds.length === 0
4
+ ? ''
5
+ : `\nProduct slots registered in this home: ${productKinds.map((kind) => `\`${kind}\``).join(', ')}. Use \`slot:<kind>\`; each is response-bearing.`;
6
+ return [
7
+ 'Markdown page (`.md`; one step only)',
8
+ 'Front matter starts at byte 0 and recognizes exactly `title` (required), `subtitle` (optional), and `placement` (`inline|panel`, optional, default `panel`); every other key is invalid. Outside front matter, write directive-flavored Markdown. A top-level `slot:<kind>` JSON fence contains one object: reserved top-level `id` identifies a response-bearing slot; every other key is that kind\'s config.',
9
+ 'Built-ins: `options` asks for one or more choices; `text` asks for text; `table` asks for row and/or column selections when enabled, otherwise displays data; `cards` asks for card selections; `chart` displays a chart and asks nothing.' + productSlots,
10
+ 'Format specimen:',
11
+ '````markdown',
12
+ '---',
13
+ 'title: Rollout scope',
14
+ 'subtitle: Choose tonight\'s rollout.',
15
+ 'placement: inline',
16
+ '---',
17
+ '',
18
+ 'Which rollout should ship?',
19
+ '',
20
+ '```slot:options',
21
+ '{"id":"rollout","mode":"single","options":[{"id":"canary","label":"Canary first"}]}',
22
+ '```',
23
+ '````',
24
+ ].join('\n');
25
+ }
26
+ function htmlAuthoring(productKinds) {
27
+ const productSlot = productKinds.length === 0
28
+ ? ''
29
+ : ' Product kinds use `<crtr-slot kind="<kind>" id="…">` with the same JSON-script child.';
30
+ return [
31
+ 'HTML page (`.html` or `.htm`)',
32
+ 'Put required title and optional subtitle in `<head>` metas named `crtr-page-title` and `crtr-page-subtitle`. Placement is declared only by `<meta name="crtr-page-placement" content="inline|panel">` in `<head>`; it defaults to `panel`.',
33
+ 'Built-ins are `<crtr-options>`, `<crtr-text>`, `<crtr-table>`, `<crtr-cards>`, and `<crtr-chart>`; `id` is an attribute and config is the first direct `<script type="application/json">` child.' + productSlot,
34
+ 'An interactive page (any response-bearing slot), or any multi-step page, requires one `<crtr-pages>` wrapper containing one or more `<crtr-page>` steps, with every response-bearing element inside a step. A pure display page is correctly bare HTML with no `crtr-*` markup at all.',
35
+ 'Format specimen:',
36
+ '```html',
37
+ '<!doctype html>',
38
+ '<html><head>',
39
+ ' <meta name="crtr-page-title" content="Rollout scope">',
40
+ ' <meta name="crtr-page-placement" content="inline">',
41
+ '</head><body>',
42
+ ' <crtr-pages><crtr-page>',
43
+ ' <crtr-options id="rollout"><script type="application/json">{"mode":"single","options":[{"id":"canary","label":"Canary first"}]}</script></crtr-options>',
44
+ ' </crtr-page></crtr-pages>',
45
+ '</body></html>',
46
+ '```',
47
+ ].join('\n');
48
+ }
49
+ /** Invocation-local page capability and authoring contract for `human -h`.
50
+ * The CLI process rebuilds command help each time, so config changes appear on
51
+ * the next invocation without a parallel documentation surface. */
52
+ export function pageHelpText() {
53
+ const config = readConfig('user');
54
+ const hardRule = '**Hard rule:** write the page to a local file and submit it with `--page PATH`; NEVER paste page markup into a message.';
55
+ const shorthand = 'Zero ceremony: `ask` stdin plus `--title` and `--subtitle` synthesizes a one-text-slot page; `notify` title plus `--subtitle` and optional `--body` synthesizes a display page.';
56
+ const dialects = config.html_pages
57
+ ? [markdownAuthoring(config.page_components), htmlAuthoring(config.page_components)]
58
+ : [markdownAuthoring(config.page_components)];
59
+ const pageHint = config.html_pages
60
+ ? 'Authored page file. This home accepts .md, .html, and .htm; the extension selects the dialect.'
61
+ : 'Authored Markdown page file. This home has html_pages off, so only .md is accepted.';
62
+ return { authoring: [hardRule, shorthand, ...dialects].join('\n\n'), pageHint };
63
+ }
15
64
  export function resolveMaxPanes() {
16
65
  return readConfig('user').max_panes_per_window;
17
66
  }
@@ -2,14 +2,16 @@
2
2
  //
3
3
  // Ask creates a bridge node and submits a ticket to the store. Review creation
4
4
  // asks crtrd to create a ticket, daemon-owned review, and companion. notify
5
- // enqueues a durable deck with no node; show is passive display.
5
+ // enqueues a durable page with no node; show is passive display.
6
6
  import { defineBranch } from '../core/command.js';
7
7
  import { humanAsk, humanNotify, humanShow } from './human/prompts.js';
8
+ import { humanList, humanResolve, humanCancel } from './human/queue.js';
8
9
  import { humanReviewBranch } from './human/review.js';
9
- import { humanList, humanDeck, humanResolve, humanCancel } from './human/queue.js';
10
10
  import { humanInbox } from './human/inbox.js';
11
11
  import { humanDoc } from './human/doc.js';
12
+ import { pageHelpText } from './human/shared.js';
12
13
  export function registerHuman() {
14
+ const pageHelp = pageHelpText();
13
15
  return defineBranch({
14
16
  name: 'human',
15
17
  rootEntry: {
@@ -20,7 +22,10 @@ export function registerHuman() {
20
22
  help: {
21
23
  name: 'human',
22
24
  summary: 'human-in-the-loop decisions, document review, and live display',
23
- model: `Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`crtr human doc check\` to validate a body before submitting it. ask creates a kind:'human' bridge node under you and returns instantly, never blocking. Nothing opens on screen — the ticket is queued in the human inbox; the human opens it on their own time, and their response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call. review is a branch: new queues a live document for the human to review in the attach viewer alongside a companion agent, while comment verbs operate on daemon-owned anchored comments; approval is pushed back as an approval fact, and a changed document instructs the origin to re-read it. notify enqueues a durable acknowledgement with no node; show is passive live display. inbox opens or toggles the queue on this screen; doc validates or renders a directive-flavored markdown body.`,
25
+ model: [
26
+ "Tickets queue in the human inbox; nothing opens on screen. `ask` returns immediately and its answer is pushed to the asking node, while `notify` expects no reply. `review` is for live document review with anchored comments; `show` is a passive tmux live-watch rather than a ticket. Validate authored pages with `crtr human doc check`.",
27
+ pageHelp.authoring,
28
+ ].join('\n\n'),
24
29
  },
25
30
  children: [
26
31
  humanAsk,
@@ -29,7 +34,6 @@ export function registerHuman() {
29
34
  humanShow,
30
35
  humanCancel,
31
36
  humanList,
32
- humanDeck,
33
37
  humanResolve,
34
38
  humanInbox,
35
39
  humanDoc,
@@ -55,6 +55,12 @@ function indexRoutes(doc) {
55
55
  if (!byName.has(d.name))
56
56
  byName.set(d.name, d);
57
57
  const prefix = dir + '/';
58
+ // Keyed by RENDERED identity (`displayName`), not `.name`: one physical
59
+ // directory INDEX answers to two names — `listAllMemoryDocs` emits the
60
+ // path-derived `dir/INDEX`, while `resolveMemoryDoc('dir')` returns it
61
+ // carrying the bare dir as its identity. Keying on `.name` let a body link
62
+ // to a sub-INDEX (written the normal way, by bare dir name) miss the entry
63
+ // the directory scan already added, and the doc rendered twice.
58
64
  const members = new Map();
59
65
  for (const [name, d] of byName) {
60
66
  if (!name.startsWith(prefix) || name === canonicalName)
@@ -70,8 +76,8 @@ function indexRoutes(doc) {
70
76
  break;
71
77
  }
72
78
  }
73
- if (!members.has(member.name))
74
- members.set(member.name, member);
79
+ if (!members.has(displayName(member.name)))
80
+ members.set(displayName(member.name), member);
75
81
  }
76
82
  // No members means this INDEX gates no cluster in the corpus namespace — a
77
83
  // store-root front door whose explicit frontmatter name dodges the root
@@ -88,10 +94,12 @@ function indexRoutes(doc) {
88
94
  catch {
89
95
  continue; // dangling link — lint's finding, not read's
90
96
  }
91
- if (displayName(linked.name) !== linkName || linked.name === canonicalName || linked.name === doc.name)
97
+ if (displayName(linked.name) !== linkName)
92
98
  continue;
93
- if (!members.has(linked.name))
94
- members.set(linked.name, linked);
99
+ if (linkName === displayName(canonicalName) || linkName === displayName(doc.name))
100
+ continue;
101
+ if (!members.has(linkName))
102
+ members.set(linkName, linked);
95
103
  }
96
104
  return [...members.values()]
97
105
  .map((m) => {
@@ -1,10 +1,9 @@
1
1
  // `crtr node bash` — file-backed background job controls.
2
- import { existsSync, writeFileSync } from 'node:fs';
3
2
  import { defineBranch, defineLeaf } from '../../core/command.js';
4
3
  import { InputError } from '../../core/io.js';
5
4
  import { displayMessage } from '../../core/runtime/tmux-chrome.js';
6
5
  import { contextDir } from '../../core/canvas/paths.js';
7
- import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../../core/bash-jobs.js';
6
+ import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog } from '../../core/bash-jobs.js';
8
7
  import { cliClient, getNodeOrNull } from '../api-client.js';
9
8
  import { nodeInPane } from '../surface/node/placement.js';
10
9
  function resolveBashTarget(input, missingMessage) {
@@ -111,17 +110,6 @@ const nodeBashList = defineLeaf({
111
110
  return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
112
111
  },
113
112
  });
114
- /** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
115
- * false only when the group was already gone at the first signal. */
116
- function stopJobGroup(pgid) {
117
- try {
118
- process.kill(-pgid, 'SIGTERM');
119
- }
120
- catch {
121
- return false;
122
- }
123
- return true;
124
- }
125
113
  const nodeBashKill = defineLeaf({
126
114
  name: 'kill',
127
115
  description: 'stop one of a node’s background bash jobs',
@@ -153,35 +141,34 @@ const nodeBashKill = defineLeaf({
153
141
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
154
142
  }
155
143
  const jobId = input['job'];
144
+ if (!isSafeBashJobId(jobId)) {
145
+ throw new InputError({ error: 'invalid_job_id', message: `invalid background job id: ${jobId}`, next: 'Use a job id reported by `crtr node bash list`.' });
146
+ }
156
147
  const dir = contextDir(node.node_id);
157
148
  const paths = bashJobPaths(dir, jobId);
158
- if (!existsSync(paths.dir)) {
149
+ const stopped = await stopBackgroundBashJob(dir, jobId);
150
+ if (stopped.kind === 'missing') {
159
151
  throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
160
152
  }
161
- const pgid = readJobPgid(dir, jobId);
162
- if (pgid === undefined) {
153
+ if (stopped.kind === 'retired') {
154
+ throw new InputError({
155
+ error: 'not_found',
156
+ message: `background job ${jobId} for ${node.node_id} is no longer running`,
157
+ next: `It finished or was already stopped, so nothing was signaled. Log: ${stopped.logPath}`,
158
+ });
159
+ }
160
+ if (stopped.kind === 'not-stoppable') {
163
161
  throw new InputError({
164
162
  error: 'not_stoppable',
165
163
  message: `job ${jobId} recorded no process group`,
166
164
  next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
167
165
  });
168
166
  }
169
- const signaled = stopJobGroup(pgid);
170
- // The supervisor shares the group it leads, so it usually dies with the
171
- // command and never writes job.exit. Record the cancellation ourselves —
172
- // that sentinel is what retires the job from every live-jobs read.
173
- await new Promise((r) => setTimeout(r, 400));
174
- try {
175
- process.kill(-pgid, 'SIGKILL');
176
- }
177
- catch { /* already gone */ }
178
- if (!existsSync(paths.jobExit))
179
- writeFileSync(paths.jobExit, '143\n');
180
167
  await cliClient().sendMessage(node.node_id, {
181
168
  body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
182
169
  tier: 'urgent',
183
170
  });
184
- return { node_id: node.node_id, job_id: jobId, signaled };
171
+ return { node_id: node.node_id, job_id: jobId, signaled: stopped.signaled };
185
172
  },
186
173
  render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
187
174
  });
@@ -30,7 +30,7 @@ export const surfaceNodeFocusLeaf = defineLeaf({
30
30
  const node = await cliClient()
31
31
  .getNode(id)
32
32
  .catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
33
- // A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
33
+ // A kind:'human' node is a control-plane ASK (a page on the human's
34
34
  // screen), NOT a pi conversation — it has no session. Reviving one boots a
35
35
  // confused blank "you have been revived" pi, so refuse rather than focus it.
36
36
  // (The nav/resume UIs already hide human nodes; this guards a hand-typed id.)
@@ -5,21 +5,18 @@ import { test, describe, before, after, beforeEach, afterEach } from 'node:test'
5
5
  import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
- import { join, resolve } from 'node:path';
8
+ import { join } from 'node:path';
9
9
  import { Readable } from 'node:stream';
10
10
  import { createApiServer } from '../../daemon/api/server.js';
11
- import { cancelHumanTicket, deliverTerminalResult, resolveDeckTicket } from '../../daemon/human/finish.js';
11
+ import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
12
12
  import { humanAsk, humanNotify } from '../../commands/human/prompts.js';
13
- import { runCli } from '../command.js';
14
13
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
15
14
  import { readInboxSince } from '../feed/inbox.js';
16
15
  import { resolveRef } from '../canvas/history.js';
17
- import { apiSocketPath, nodeDir } from '../canvas/paths.js';
16
+ import { apiSocketPath } from '../canvas/paths.js';
18
17
  import { spawnNode } from '../runtime/nodes.js';
19
- import { resolveRoot } from '../../build-root.js';
20
- import { validateDeck } from '../human/deck-schema.js';
21
18
  import { ticketDir } from '../human/root.js';
22
- import { readTicketResult, submitDeck } from '../human/tickets.js';
19
+ import { readTicketResult, submitPage } from '../human/tickets.js';
23
20
  let home;
24
21
  let priorHome;
25
22
  let server;
@@ -58,15 +55,27 @@ function bridgeUnder() {
58
55
  const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
59
56
  return { parent, bridge };
60
57
  }
61
- function submitBridgeDeck(bridge) {
58
+ function submitBridgePage(bridge) {
62
59
  const dir = ticketDir(bridge);
63
- submitDeck({
64
- dir,
65
- deck: validateDeck({
66
- title: 'Ship it?',
67
- interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
68
- }),
69
- });
60
+ const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-deliver-page-'));
61
+ const sourceFile = join(sourceDir, 'page.md');
62
+ writeFileSync(sourceFile, [
63
+ '---',
64
+ 'title: Ship it?',
65
+ 'subtitle: Decide whether to ship now so the release can proceed.',
66
+ '---',
67
+ '',
68
+ '```slot:text',
69
+ '{ "id": "question", "initialText": "", "editable": true }',
70
+ '```',
71
+ '',
72
+ ].join('\n'));
73
+ try {
74
+ submitPage({ dir, sourceFile, dialect: 'md' });
75
+ }
76
+ finally {
77
+ rmSync(sourceDir, { recursive: true, force: true });
78
+ }
70
79
  return dir;
71
80
  }
72
81
  function reportBody(ref) {
@@ -110,47 +119,32 @@ describe('enqueue is producer-only', () => {
110
119
  assert.equal(typeof result['dir'], 'string');
111
120
  for (const forbidden of ['pane_id', 'render_pane_id', 'pid', 'worker_pid'])
112
121
  assert.ok(!(forbidden in result), `ask result must not expose ${forbidden}`);
113
- const deck = JSON.parse(readFileSync(join(result['dir'], 'deck.json'), 'utf8'));
114
- assert.equal(deck.title, 'Release decision');
115
- assert.equal(deck.interactions[0]?.title, 'Release decision');
116
- assert.equal(deck.interactions[0]?.subtitle, 'Decide whether to release now so the launch can proceed.');
117
- assert.equal(deck.interactions[0]?.body, 'This first line must never become the title.');
118
- assert.equal(deck.interactions[0]?.kind, 'decision');
119
- const deckDirectory = mkdtempSync(join(tmpdir(), 'crtr-context-file-deck-'));
120
- assert.notEqual(resolve(process.cwd()), deckDirectory, 'the CLI caller cwd must differ from the deck directory');
121
- const deckPath = join(deckDirectory, 'deck.json');
122
- writeFileSync(join(deckDirectory, 'body.md'), 'The body came from beside the deck JSON.');
123
- writeFileSync(deckPath, JSON.stringify({ title: 'Context file body', interactions: [{ id: 'decision', title: 'Use the deck', subtitle: 'Use the deck body so the person can decide with its context.', bodyPath: 'body.md', options: [{ id: 'yes', label: 'Yes' }] }] }));
124
- const nodesBefore = new Set(listNodes().map((node) => node.node_id));
125
- await withMutedStdout(() => withPipedStdin('', async () => {
126
- await runCli(await resolveRoot('human'), ['node', 'crtr', 'human', 'ask', '--context-file', deckPath]);
127
- }));
128
- const bridge = listNodes().find((node) => !nodesBefore.has(node.node_id));
129
- assert.ok(bridge, 'the real CLI invocation created a human bridge');
130
- const contextDeck = JSON.parse(readFileSync(join(nodeDir(bridge.node_id), 'deck.json'), 'utf8'));
131
- assert.equal(contextDeck.interactions[0]?.body, 'The body came from beside the deck JSON.');
132
- assert.equal(contextDeck.interactions[0]?.bodyPath, undefined);
122
+ const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
123
+ assert.equal(manifest.title, 'Release decision');
124
+ assert.equal(manifest.subtitle, 'Decide whether to release now so the launch can proceed.');
125
+ const document = readFileSync(join(result['dir'], 'page.md'), 'utf8');
126
+ assert.ok(document.includes('This first line must never become the title.'));
127
+ assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1, 'the synthesized question page carries one response slot');
133
128
  });
134
129
  });
135
130
  describe('terminal ticket delivery', () => {
136
- test('resolveDeckTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
131
+ test('resolvePageTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
137
132
  const { parent, bridge } = bridgeUnder();
138
- const dir = submitBridgeDeck(bridge);
139
- const result = await resolveDeckTicket(bridge, [{ id: 'question', freetext: 'yes, ship' }]);
140
- assert.equal(result.kind, 'deck');
133
+ const dir = submitBridgePage(bridge);
134
+ const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true, comments: [] } });
135
+ assert.equal(result.kind, 'page');
141
136
  closeDb();
142
137
  assert.equal(getNode(bridge)?.status, 'done');
143
138
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
144
139
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
145
140
  const body = reportBody(finals[0].ref);
146
- assert.equal(body.split('\n')[0], 'Ship it?: yes, ship');
141
+ assert.ok(body.includes('"text":"yes, ship"'), 'the slot answer is inlined in the report');
147
142
  assert.ok(body.includes(join(dir, 'response.json')));
148
- assert.ok(!body.includes('humanloop.response/v2'));
149
143
  });
150
144
  test('a replay against a terminal bridge is a no-op', async () => {
151
145
  const { parent, bridge } = bridgeUnder();
152
- submitBridgeDeck(bridge);
153
- await resolveDeckTicket(bridge, [{ id: 'question', freetext: 'yes' }]);
146
+ submitBridgePage(bridge);
147
+ await resolvePageTicket(bridge, { question: { text: 'yes', edited: true, comments: [] } });
154
148
  assert.equal(await deliverTerminalResult(bridge), true);
155
149
  closeDb();
156
150
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
@@ -162,7 +156,7 @@ describe('terminal ticket delivery', () => {
162
156
  const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
163
157
  const { subscribe } = await import('../canvas/canvas.js');
164
158
  subscribe(other, bridge, true);
165
- submitBridgeDeck(bridge);
159
+ submitBridgePage(bridge);
166
160
  await cancelHumanTicket(bridge, { reason: 'stale', actor });
167
161
  closeDb();
168
162
  assert.equal(getNode(bridge)?.status, 'done');
@@ -173,12 +167,12 @@ describe('terminal ticket delivery', () => {
173
167
  assert.match(notes[0].label, /canceled/);
174
168
  });
175
169
  });
176
- test('notify enqueues a durable deck and creates no canvas node', async () => {
170
+ test('notify enqueues a durable page and creates no canvas node', async () => {
177
171
  const before = listNodes().length;
178
172
  const result = (await humanNotify.run({ title: 'Build done', subtitle: 'The build passed, so the change is ready to review.' }));
179
173
  assert.equal(result['queued'], true);
180
174
  assert.equal(typeof result['dir'], 'string');
181
- assert.ok(existsSync(join(result['dir'], 'deck.json')));
175
+ assert.ok(existsSync(join(result['dir'], 'page.json')));
182
176
  assert.ok(readTicketResult(result['dir']) === null);
183
177
  closeDb();
184
178
  assert.equal(listNodes().length, before);
@@ -75,7 +75,7 @@ test('G1 — controller prompt runs the engine and the streamed AgentSessionEven
75
75
  // ran AND its terminal frame reached the client; the intermediate types prove
76
76
  // the stream (not just a final blob) was fanned out.
77
77
  await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1 agent_end carrying the prompt token');
78
- for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
78
+ for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_update', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
79
79
  assert.ok(c.frames.some((f) => f.type === t), `G1: client received a relayed ${t} frame`);
80
80
  }
81
81
  assert.ok(c.frames.some((f) => f.type === 'message_start' && frameHas(f, token)), 'G1: a relayed message carries the prompt token');
@@ -101,6 +101,12 @@ test('G1b — coalesced message_update never arrives after message_end; updates
101
101
  const msgEnd = types.indexOf('message_end');
102
102
  assert.ok(lastUpdate >= 0, 'G1b: at least one message_update relayed (coalescing must not starve updates)');
103
103
  assert.ok(msgEnd >= 0, 'G1b: message_end relayed');
104
+ const updates = c.frames.filter((frame) => frame.type === 'message_update');
105
+ assert.ok(updates.length < 12, `G1b: ${updates.length} relayed updates proves the 12-update burst was coalesced`);
106
+ for (const update of updates) {
107
+ assert.equal(update.message.role, 'assistant', 'G1b: crouter relays the in-process cumulative message_update contract');
108
+ assert.ok('partial' in update.assistantMessageEvent, 'G1b: crouter does not route pi JSON/RPC delta-only events through view.sock');
109
+ }
104
110
  assert.ok(lastUpdate < msgEnd, `G1b: a message_update arrived AFTER message_end (update@${lastUpdate} vs end@${msgEnd}) — the coalescer flushed out of order`);
105
111
  // tool/turn/agent frames must also never precede a stale held update.
106
112
  for (const t of ['tool_execution_start', 'turn_end', 'agent_end']) {
@@ -4,16 +4,15 @@
4
4
  // delivery is daemon-owned; no subprocess handler or delivery receipt exists.
5
5
  import { test, before, beforeEach, afterEach, after } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { mkdtempSync, existsSync, writeFileSync } from 'node:fs';
7
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { ApiError, CrtrClient } from '../../../api/index.js';
11
11
  import { createApiServer } from '../../../daemon/api/server.js';
12
12
  import { apiSocketPath } from '../../canvas/paths.js';
13
- import { validateDeck } from '../../human/deck-schema.js';
14
13
  import { claimTicket, readTicketClaim } from '../../human/claim.js';
15
14
  import { ticketDir } from '../../human/root.js';
16
- import { submitDeck, submitReview } from '../../human/tickets.js';
15
+ import { submitPage, submitReview } from '../../human/tickets.js';
17
16
  import { spawnNode } from '../../runtime/nodes.js';
18
17
  import { getNode, closeDb } from '../../canvas/index.js';
19
18
  import { readInboxSince } from '../../feed/inbox.js';
@@ -63,6 +62,30 @@ async function expectBadResolve(nodeId, body) {
63
62
  async function expectReviewSubmitGuidance(nodeId, body) {
64
63
  await assert.rejects(() => client.request('POST', `/v1/human/tickets/${nodeId}/resolve`, body), (error) => error instanceof ApiError && error.status === 400 && error.code === 'invalid_request' && error.message.includes('POST /v1/human/reviews/:review_id/submit'));
65
64
  }
65
+ /** Publish a one-text-slot page ticket into the bridge's ticket directory. */
66
+ function submitBridgePage(bridge) {
67
+ const dir = ticketDir(bridge);
68
+ const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-page-'));
69
+ const sourceFile = join(sourceDir, 'page.md');
70
+ writeFileSync(sourceFile, [
71
+ '---',
72
+ 'title: Ship it?',
73
+ 'subtitle: Decide whether to ship now so the release can proceed.',
74
+ '---',
75
+ '',
76
+ '```slot:text',
77
+ '{ "id": "question", "initialText": "", "editable": true }',
78
+ '```',
79
+ '',
80
+ ].join('\n'));
81
+ try {
82
+ submitPage({ dir, sourceFile, dialect: 'md' });
83
+ }
84
+ finally {
85
+ rmSync(sourceDir, { recursive: true, force: true });
86
+ }
87
+ return dir;
88
+ }
66
89
  function reportBody(ref) {
67
90
  const resolved = resolveRef(ref);
68
91
  if (resolved === null || resolved.source !== 'report')
@@ -71,24 +94,17 @@ function reportBody(ref) {
71
94
  }
72
95
  test('malformed resolves leave the mounted ticket claim in place', async () => {
73
96
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
74
- const deckBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-deck', lifecycle: 'terminal' }).node_id;
75
- const deckDir = ticketDir(deckBridge);
76
- submitDeck({
77
- dir: deckDir,
78
- deck: validateDeck({
79
- title: 'Ship it?',
80
- interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
81
- }),
82
- });
83
- const deckClaim = claimTicket(deckDir);
84
- assert.ok(deckClaim !== null);
85
- for (const body of [{ responses: [null] }, { response: [] }, { responses: [], debug: true }]) {
86
- await expectBadResolve(deckBridge, body);
87
- assert.equal(readTicketClaim(deckDir)?.token, deckClaim.token, 'malformed deck input must not take over the live claim');
97
+ const pageBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
98
+ const pageDir = submitBridgePage(pageBridge);
99
+ const pageClaim = claimTicket(pageDir);
100
+ assert.ok(pageClaim !== null);
101
+ for (const body of [{ responses: [] }, { response: {} }, { responses: {}, debug: true }]) {
102
+ await expectBadResolve(pageBridge, body);
103
+ assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'malformed page input must not take over the live claim');
88
104
  }
89
- for (const body of [{ responses: [], feedback: {} }, { feedback: [] }]) {
90
- await expectReviewSubmitGuidance(deckBridge, body);
91
- assert.equal(readTicketClaim(deckDir)?.token, deckClaim.token, 'feedback-shaped input must not take over the live claim');
105
+ for (const body of [{ responses: {}, feedback: {} }, { feedback: [] }]) {
106
+ await expectReviewSubmitGuidance(pageBridge, body);
107
+ assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
92
108
  }
93
109
  const reviewBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
94
110
  const reviewDir = ticketDir(reviewBridge);
@@ -97,22 +113,15 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
97
113
  submitReview({ dir: reviewDir, review: { title: 'Review', subtitle: 'Review this file so implementation can proceed safely.', source: {}, file } });
98
114
  const reviewClaim = claimTicket(reviewDir);
99
115
  assert.ok(reviewClaim !== null);
100
- await expectReviewSubmitGuidance(reviewBridge, { responses: [] });
116
+ await expectReviewSubmitGuidance(reviewBridge, { responses: {} });
101
117
  assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
102
118
  });
103
119
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
104
120
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
105
121
  const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
106
- const dir = ticketDir(bridge);
107
- submitDeck({
108
- dir,
109
- deck: validateDeck({
110
- title: 'Ship it?',
111
- interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
112
- }),
113
- });
114
- const result = await client.resolveHumanTicket(bridge, { responses: [{ id: 'question', freetext: 'yes, ship' }] });
115
- assert.deepEqual(result, { delivered: true, kind: 'deck' });
122
+ const dir = submitBridgePage(bridge);
123
+ const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true, comments: [] } } });
124
+ assert.deepEqual(result, { delivered: true, kind: 'page' });
116
125
  assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
117
126
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
118
127
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createServer } from 'node:net';
10
10
  import { spawn as spawnProcess } from 'node:child_process';
11
+ import { findDaemonPids, stopDaemonProcess } from '../../../daemon/manage.js';
11
12
  import { createNode, getNode, getRow, subscribersOf } from '../../canvas/canvas.js';
12
13
  import { closeDb } from '../../canvas/db.js';
13
14
  import { nodeDir } from '../../canvas/paths.js';
@@ -40,8 +41,9 @@ beforeEach(() => {
40
41
  closeDb();
41
42
  rmSync(home, { recursive: true, force: true });
42
43
  });
43
- after(() => {
44
+ after(async () => {
44
45
  closeDb();
46
+ await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
45
47
  rmSync(home, { recursive: true, force: true });
46
48
  });
47
49
  test('managed child: parent on the spine + active subscription + provenance', () => {
@@ -18,6 +18,7 @@ import { closeDb } from '../canvas/db.js';
18
18
  import { spawnNode } from '../runtime/nodes.js';
19
19
  import { spawnChild } from '../runtime/spawn.js';
20
20
  import { managedWorktreePath } from '../worktree.js';
21
+ import { findDaemonPids, stopDaemonProcess } from '../../daemon/manage.js';
21
22
  let home;
22
23
  let repoDir;
23
24
  function run(args, cwd) {
@@ -41,8 +42,9 @@ beforeEach(() => {
41
42
  rmSync(home, { recursive: true, force: true });
42
43
  process.env['CRTR_HOME'] = home;
43
44
  });
44
- after(() => {
45
+ after(async () => {
45
46
  closeDb();
47
+ await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
46
48
  rmSync(home, { recursive: true, force: true });
47
49
  rmSync(repoDir, { recursive: true, force: true });
48
50
  });
@@ -1,4 +1,4 @@
1
- import type { AuthInteraction } from '@earendil-works/pi-ai';
1
+ import type { ProviderAuthInteraction } from '@earendil-works/pi-ai';
2
2
  import type { LoginDialogComponent } from '@earendil-works/pi-coding-agent';
3
3
  export type DialogInteractionOptions = {
4
4
  /** Open the verification URL for `auth_url` / `device_code` events. */
@@ -16,4 +16,4 @@ export type DialogInteractionOptions = {
16
16
  * the dialog occupies crouter's single inline slot in both surfaces, so swapping in pi's
17
17
  * separate selector component (what pi's own interactive mode does) has nowhere to go.
18
18
  */
19
- export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions): AuthInteraction;
19
+ export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions): ProviderAuthInteraction;