@north-light/crouter 0.3.192 → 0.3.194

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 (190) 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 +24 -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/02-lifecycle/01-resident.md +1 -1
  13. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  14. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  15. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  20. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  21. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  22. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  23. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  24. package/dist/clients/attach/input/overlay-owner.js +1 -1
  25. package/dist/clients/attach/overlays/auth.js +34 -8
  26. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  27. package/dist/clients/attach/overlays/pickers.js +37 -43
  28. package/dist/clients/attach/viewer.js +643 -620
  29. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  30. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  31. package/dist/clients/inbox/controller.d.ts +2 -3
  32. package/dist/clients/inbox/controller.js +41 -42
  33. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  34. package/dist/clients/inbox/page-adapter.js +54 -0
  35. package/dist/clients/inbox/resolve.d.ts +2 -2
  36. package/dist/clients/inbox/resolve.js +1 -1
  37. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  38. package/dist/clients/inbox/review-visibility.js +1 -1
  39. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  40. package/dist/clients/inbox/tui/ansi.js +2 -2
  41. package/dist/clients/inbox/tui/input.d.ts +1 -3
  42. package/dist/clients/inbox/tui/input.js +242 -553
  43. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  44. package/dist/clients/inbox/tui/panel.js +59 -187
  45. package/dist/clients/inbox/tui/render.d.ts +0 -25
  46. package/dist/clients/inbox/tui/render.js +165 -416
  47. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  48. package/dist/clients/inbox/tui/slots.js +156 -0
  49. package/dist/clients/inbox/tui/types.d.ts +25 -13
  50. package/dist/clients/inbox/tui.js +5 -3
  51. package/dist/commands/attention.js +2 -2
  52. package/dist/commands/human/doc.js +66 -20
  53. package/dist/commands/human/prompts.d.ts +3 -3
  54. package/dist/commands/human/prompts.js +158 -112
  55. package/dist/commands/human/queue.d.ts +0 -1
  56. package/dist/commands/human/queue.js +34 -94
  57. package/dist/commands/human/shared.d.ts +10 -4
  58. package/dist/commands/human/shared.js +62 -13
  59. package/dist/commands/human.js +8 -4
  60. package/dist/commands/memory/read.js +13 -5
  61. package/dist/commands/node/bash.js +18 -30
  62. package/dist/commands/node/lifecycle.js +2 -2
  63. package/dist/commands/surface/node/focus.js +1 -1
  64. package/dist/core/__tests__/human-deliver.test.js +40 -46
  65. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  66. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  67. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  68. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  69. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  70. package/dist/core/auth-interaction.d.ts +2 -2
  71. package/dist/core/bash-jobs.d.ts +43 -0
  72. package/dist/core/bash-jobs.js +96 -2
  73. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  74. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  75. package/dist/core/canvas/attention.js +1 -1
  76. package/dist/core/canvas/browse/app.js +1 -1
  77. package/dist/core/canvas/nav-model.d.ts +1 -1
  78. package/dist/core/canvas/nav-model.js +1 -1
  79. package/dist/core/canvas/remote-canvas-source.js +1 -1
  80. package/dist/core/canvas/types.d.ts +2 -1
  81. package/dist/core/config.js +4 -1
  82. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  83. package/dist/core/human/__tests__/page-render.test.js +49 -0
  84. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  85. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  86. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  87. package/dist/core/human/__tests__/page.test.js +139 -0
  88. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  89. package/dist/core/human/claim.js +2 -2
  90. package/dist/core/human/convention.d.ts +4 -4
  91. package/dist/core/human/convention.js +6 -13
  92. package/dist/core/human/markdown-html.d.ts +8 -0
  93. package/dist/core/human/markdown-html.js +581 -0
  94. package/dist/core/human/page-catalog.d.ts +6 -0
  95. package/dist/core/human/page-catalog.js +43 -0
  96. package/dist/core/human/page-render.d.ts +3 -0
  97. package/dist/core/human/page-render.js +131 -0
  98. package/dist/core/human/page-schema.d.ts +312 -0
  99. package/dist/core/human/page-schema.js +291 -0
  100. package/dist/core/human/page-synth.d.ts +9 -0
  101. package/dist/core/human/page-synth.js +17 -0
  102. package/dist/core/human/page.d.ts +22 -0
  103. package/dist/core/human/page.js +427 -0
  104. package/dist/core/human/review-schema.d.ts +19 -0
  105. package/dist/core/human/review-schema.js +34 -0
  106. package/dist/core/human/scan.d.ts +12 -3
  107. package/dist/core/human/scan.js +69 -26
  108. package/dist/core/human/summary.d.ts +5 -6
  109. package/dist/core/human/summary.js +54 -37
  110. package/dist/core/human/tickets.d.ts +61 -11
  111. package/dist/core/human/tickets.js +100 -57
  112. package/dist/core/human/types.d.ts +21 -66
  113. package/dist/core/human/types.js +1 -5
  114. package/dist/core/inspector/model.d.ts +5 -0
  115. package/dist/core/inspector/model.js +5 -0
  116. package/dist/core/inspector/text.js +2 -2
  117. package/dist/core/inspector/tui.js +10 -3
  118. package/dist/core/keybindings/catalog.d.ts +1 -1
  119. package/dist/core/keybindings/catalog.js +4 -2
  120. package/dist/core/preview-registry.js +0 -1
  121. package/dist/core/review/ticket-filter.d.ts +1 -1
  122. package/dist/core/review/ticket-filter.js +2 -8
  123. package/dist/core/runtime/broker/read-ops.js +3 -0
  124. package/dist/core/runtime/broker.js +19 -10
  125. package/dist/core/runtime/package-health.js +1 -1
  126. package/dist/core/runtime/persona.js +2 -1
  127. package/dist/core/runtime/tmux-bindings.js +8 -1
  128. package/dist/core/termrender/termrender.d.ts +1 -1
  129. package/dist/core/termrender/termrender.js +2 -2
  130. package/dist/core/termrender/version.d.ts +1 -1
  131. package/dist/core/termrender/version.js +1 -1
  132. package/dist/core/tui/page-host.d.ts +1 -1
  133. package/dist/core/tui/page-host.js +2 -2
  134. package/dist/core/user-settings.d.ts +19 -0
  135. package/dist/core/user-settings.js +22 -0
  136. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  137. package/dist/daemon/api/handlers/bash-jobs.js +90 -0
  138. package/dist/daemon/api/handlers/human.js +16 -14
  139. package/dist/daemon/api/handlers/inbox.js +311 -227
  140. package/dist/daemon/api/router.d.ts +5 -1
  141. package/dist/daemon/api/router.js +5 -0
  142. package/dist/daemon/api/server.js +2 -0
  143. package/dist/daemon/cron-run.js +23 -10
  144. package/dist/daemon/human/finish.d.ts +8 -3
  145. package/dist/daemon/human/finish.js +35 -15
  146. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  147. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  148. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  149. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  150. package/dist/pages/bundle.css +1 -0
  151. package/dist/pages/bundle.js +1347 -0
  152. package/dist/pages/comments.d.ts +34 -0
  153. package/dist/pages/comments.js +72 -0
  154. package/dist/pages/elements/cards.d.ts +17 -0
  155. package/dist/pages/elements/cards.js +785 -0
  156. package/dist/pages/elements/chart.d.ts +1 -0
  157. package/dist/pages/elements/chart.js +648 -0
  158. package/dist/pages/elements/options.d.ts +21 -0
  159. package/dist/pages/elements/options.js +604 -0
  160. package/dist/pages/elements/pages.d.ts +24 -0
  161. package/dist/pages/elements/pages.js +355 -0
  162. package/dist/pages/elements/slot.d.ts +21 -0
  163. package/dist/pages/elements/slot.js +105 -0
  164. package/dist/pages/elements/table.d.ts +19 -0
  165. package/dist/pages/elements/table.js +906 -0
  166. package/dist/pages/elements/text.d.ts +25 -0
  167. package/dist/pages/elements/text.js +951 -0
  168. package/dist/pages/entry.d.ts +8 -0
  169. package/dist/pages/entry.js +8 -0
  170. package/dist/pages/host.d.ts +132 -0
  171. package/dist/pages/host.js +149 -0
  172. package/dist/pages/register.d.ts +2 -0
  173. package/dist/pages/register.js +3 -0
  174. package/dist/pages/slot-config.d.ts +36 -0
  175. package/dist/pages/slot-config.js +50 -0
  176. package/dist/pages/types.d.ts +98 -0
  177. package/dist/pages/types.js +23 -0
  178. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  179. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  180. package/dist/types.d.ts +4 -0
  181. package/dist/types.js +2 -0
  182. package/package.json +8 -8
  183. package/runtime.lock.json +156 -101
  184. package/scripts/install-runtime.mjs +172 -5
  185. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  186. package/dist/clients/inbox/deck-adapter.js +0 -63
  187. package/dist/core/human/deck-factories.d.ts +0 -8
  188. package/dist/core/human/deck-factories.js +0 -15
  189. package/dist/core/human/deck-schema.d.ts +0 -70
  190. 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) {
@@ -79,7 +78,7 @@ const nodeBashList = defineLeaf({
79
78
  ],
80
79
  output: [
81
80
  { name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
82
- { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
81
+ { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null.' },
83
82
  ],
84
83
  outputKind: 'object',
85
84
  effects: ['Reads the node context jobs directory. Changes nothing.'],
@@ -96,6 +95,7 @@ const nodeBashList = defineLeaf({
96
95
  const jobs = activeBackgroundBashJobs(dir).map((job) => ({
97
96
  job_id: job.jobId,
98
97
  command: job.command,
98
+ purpose: job.purpose,
99
99
  started_at: new Date(job.startedAtMs).toISOString(),
100
100
  elapsed_ms: now - job.startedAtMs,
101
101
  log_path: job.logPath,
@@ -108,20 +108,9 @@ const nodeBashList = defineLeaf({
108
108
  const jobs = result['jobs'];
109
109
  if (jobs.length === 0)
110
110
  return `No background bash jobs for ${result['node_id']}.`;
111
- return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
111
+ return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}\n log: ${job.log_path}`).join('\n');
112
112
  },
113
113
  });
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
114
  const nodeBashKill = defineLeaf({
126
115
  name: 'kill',
127
116
  description: 'stop one of a node’s background bash jobs',
@@ -153,35 +142,34 @@ const nodeBashKill = defineLeaf({
153
142
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
154
143
  }
155
144
  const jobId = input['job'];
145
+ if (!isSafeBashJobId(jobId)) {
146
+ throw new InputError({ error: 'invalid_job_id', message: `invalid background job id: ${jobId}`, next: 'Use a job id reported by `crtr node bash list`.' });
147
+ }
156
148
  const dir = contextDir(node.node_id);
157
149
  const paths = bashJobPaths(dir, jobId);
158
- if (!existsSync(paths.dir)) {
150
+ const stopped = await stopBackgroundBashJob(dir, jobId);
151
+ if (stopped.kind === 'missing') {
159
152
  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
153
  }
161
- const pgid = readJobPgid(dir, jobId);
162
- if (pgid === undefined) {
154
+ if (stopped.kind === 'retired') {
155
+ throw new InputError({
156
+ error: 'not_found',
157
+ message: `background job ${jobId} for ${node.node_id} is no longer running`,
158
+ next: `It finished or was already stopped, so nothing was signaled. Log: ${stopped.logPath}`,
159
+ });
160
+ }
161
+ if (stopped.kind === 'not-stoppable') {
163
162
  throw new InputError({
164
163
  error: 'not_stoppable',
165
164
  message: `job ${jobId} recorded no process group`,
166
165
  next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
167
166
  });
168
167
  }
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
168
  await cliClient().sendMessage(node.node_id, {
181
169
  body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
182
170
  tier: 'urgent',
183
171
  });
184
- return { node_id: node.node_id, job_id: jobId, signaled };
172
+ return { node_id: node.node_id, job_id: jobId, signaled: stopped.signaled };
185
173
  },
186
174
  render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
187
175
  });
@@ -130,7 +130,7 @@ export const nodeConfig = defineLeaf({
130
130
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
131
131
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
132
132
  { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Model spec: exact provider/id, capability tier (ultra|strong|medium|light), family alias (opus|sonnet|haiku), or a free-text substring when the broker is live. Dormant nodes require a provider/id, tier, or alias.' },
133
- { kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable and wakes on inbox/human.' },
133
+ { kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable while attached or awaiting something live.' },
134
134
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here.' },
135
135
  { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
136
136
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
@@ -242,7 +242,7 @@ export const nodePromote = defineLeaf({
242
242
  summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
243
243
  params: [
244
244
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
245
- { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
245
+ { kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, stays wakeable while attached or awaiting something live, and completes after the unattended grace otherwise. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
246
246
  { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
247
247
  { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
248
248
  ],
@@ -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']) {
@@ -6,10 +6,10 @@
6
6
  // (1) Issue #222/#353 — a real idle-release cycle must settle the inbox watcher
7
7
  // before the broker exits, and the daemon may only revive for a row strictly
8
8
  // after the committed cursor.
9
- // (2) The observed 57-broker / ~5.4GB leak — a boot-revived settled node that
10
- // receives no new work must still traverse bounded release: DAEMON
11
- // SUPERVISION, not an agent event, owns the unattended park clock, and the
12
- // exit policy frees the fleet slot without reviving.
9
+ // (2) The observed 57-broker / ~5.4GB leak — DAEMON SUPERVISION, not an agent
10
+ // event, owns the unattended clock. A resident with no live obligation
11
+ // completes into prunable history; one awaiting a child remains wakeable.
12
+ // Both release their broker and fleet slot.
13
13
  //
14
14
  // Both drive a real broker process through the real watcher/supervision paths, so
15
15
  // they belong to the compiled seam tier; neither needs tmux.
@@ -110,7 +110,20 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
110
110
  await h.tick();
111
111
  assert.equal(h.node(manager).cycles ?? 0, cyclesAfterRelease + 1, 'the settled later row does not cause a duplicate revival');
112
112
  });
113
- test('a revived settled node with no work releases on the daemon park clock and leaves no broker', { timeout: 30_000 }, async () => {
113
+ test('an already idle resident with no live obligation reconciles into completed history', async () => {
114
+ const nodeId = h.fabricateBrokerNode({
115
+ id: 'idle-resident-without-obligation',
116
+ parent: root,
117
+ lifecycle: 'resident',
118
+ status: 'idle',
119
+ intent: 'idle-release',
120
+ pi_session_id: 'parked-session',
121
+ });
122
+ await h.tick();
123
+ assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
124
+ assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
125
+ });
126
+ test('an unattended resident with no live obligation completes on the daemon clock', { timeout: 30_000 }, async () => {
114
127
  // The park clock is read per supervision pass (unattendedParkMsForDaemon), so
115
128
  // it is armed for THIS test only — a file-global 1500ms park clock would also
116
129
  // apply to the idle-release manager above and race its own release path.
@@ -120,6 +133,7 @@ test('a revived settled node with no work releases on the daemon park clock and
120
133
  const nodeId = h.fabricateBrokerNode({
121
134
  id: 'settled-no-work',
122
135
  parent: root,
136
+ lifecycle: 'resident',
123
137
  status: 'idle',
124
138
  intent: 'idle-release',
125
139
  });
@@ -141,15 +155,55 @@ test('a revived settled node with no work releases on the daemon park clock and
141
155
  await h.tick(seenAt + 1500);
142
156
  await h.waitFor(() => {
143
157
  const node = h.node(nodeId);
144
- return node?.status === 'idle' && node.intent === 'idle-release' && !isPidAlive(boot.pid);
145
- }, { timeoutMs: 10_000, intervalMs: 20, label: 'park-clock release and broker exit' });
158
+ return node?.status === 'done' && node.intent === 'done' && !isPidAlive(boot.pid);
159
+ }, { timeoutMs: 10_000, intervalMs: 20, label: 'unattended completion and broker exit' });
146
160
  // The pid vanishing is not the fleet learning about it: wait for the child
147
161
  // 'exit' delivery + its policy job, which is what actually frees the slot.
148
162
  await h.awaitFleetExit(nodeId);
149
- assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the idle-release broker');
150
- assert.equal(h.node(nodeId)?.intent, 'idle-release', 'the row remains durably dormant');
163
+ assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
164
+ assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
151
165
  await h.tick();
152
- assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive without inbox work');
166
+ assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
167
+ }
168
+ finally {
169
+ if (priorParkMs === undefined)
170
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
171
+ else
172
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
173
+ }
174
+ });
175
+ test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
176
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
177
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
178
+ try {
179
+ const nodeId = h.fabricateBrokerNode({
180
+ id: 'settled-awaiting-child',
181
+ parent: root,
182
+ lifecycle: 'resident',
183
+ status: 'idle',
184
+ intent: 'idle-release',
185
+ });
186
+ const child = h.fabricateBrokerNode({ parent: nodeId, status: 'idle', intent: 'idle-release' });
187
+ subscribe(nodeId, child, true);
188
+ updateNode(nodeId, {
189
+ launch: {
190
+ extensions: [],
191
+ tools: [],
192
+ systemPrompt: '',
193
+ env: {},
194
+ },
195
+ });
196
+ reviveNode(nodeId, { resume: true });
197
+ const boot = await h.awaitBoot(nodeId);
198
+ const seenAt = 20_000;
199
+ await h.tick(seenAt);
200
+ await h.tick(seenAt + 1500);
201
+ await h.waitFor(() => {
202
+ const node = h.node(nodeId);
203
+ return node?.status === 'idle' && node.intent === 'idle-release' && !isPidAlive(boot.pid);
204
+ }, { timeoutMs: 10_000, intervalMs: 20, label: 'awaiting resident release and broker exit' });
205
+ await h.awaitFleetExit(nodeId);
206
+ assert.equal(h.node(nodeId)?.status, 'idle', 'a live child keeps the resident eligible for an automatic wake');
153
207
  }
154
208
  finally {
155
209
  if (priorParkMs === undefined)