@north-light/crouter 0.3.204 → 0.3.205

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 (93) hide show
  1. package/dist/api/dto/broker.d.ts +2 -0
  2. package/dist/api/dto/nodes.d.ts +8 -5
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +1 -0
  5. package/dist/clients/attach/__tests__/context-message.test.js +54 -19
  6. package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/page-block.test.js +54 -0
  8. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  9. package/dist/clients/attach/render/chat-view.js +56 -27
  10. package/dist/clients/attach/render/context-message.d.ts +6 -0
  11. package/dist/clients/attach/render/context-message.js +21 -3
  12. package/dist/clients/attach/render/group-activity.d.ts +5 -3
  13. package/dist/clients/attach/render/group-activity.js +10 -6
  14. package/dist/clients/attach/render/page-block.js +11 -11
  15. package/dist/clients/attach/viewer.js +717 -703
  16. package/dist/clients/conversation/projection.js +7 -37
  17. package/dist/commands/node/create.js +1 -1
  18. package/dist/commands/profile/project.js +1 -1
  19. package/dist/commands/profile/show.js +1 -1
  20. package/dist/commands/profile.js +1 -1
  21. package/dist/core/__tests__/canvas-inbox-watcher.test.js +88 -21
  22. package/dist/core/__tests__/human-deliver.test.js +32 -4
  23. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
  24. package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
  25. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
  26. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
  27. package/dist/core/__tests__/serial/revive.test.js +8 -3
  28. package/dist/core/__tests__/session-cycles.test.js +10 -6
  29. package/dist/core/canvas/render-source.js +3 -0
  30. package/dist/core/feed/feed.d.ts +7 -1
  31. package/dist/core/feed/feed.js +8 -7
  32. package/dist/core/feed/inbox.d.ts +16 -12
  33. package/dist/core/feed/inbox.js +56 -56
  34. package/dist/core/human/page-eval.d.ts +9 -4
  35. package/dist/core/human/page-eval.js +3 -1
  36. package/dist/core/human/page-markdown.d.ts +3 -0
  37. package/dist/core/human/page-markdown.js +295 -0
  38. package/dist/core/human/page.js +4 -0
  39. package/dist/core/runtime/broker/client-registry.d.ts +1 -0
  40. package/dist/core/runtime/broker/client-registry.js +1 -0
  41. package/dist/core/runtime/broker/fault-retry.js +8 -3
  42. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  43. package/dist/core/runtime/broker/frame-dispatch.js +4 -2
  44. package/dist/core/runtime/broker/inbox.d.ts +14 -2
  45. package/dist/core/runtime/broker/inbox.js +52 -25
  46. package/dist/core/runtime/broker/passive.d.ts +1 -0
  47. package/dist/core/runtime/broker/tool-groups.js +3 -2
  48. package/dist/core/runtime/broker-protocol.d.ts +9 -0
  49. package/dist/core/runtime/broker-protocol.js +11 -0
  50. package/dist/core/runtime/broker.d.ts +2 -2
  51. package/dist/core/runtime/broker.js +31 -6
  52. package/dist/core/runtime/deliver-live.js +2 -1
  53. package/dist/core/runtime/kickoff.d.ts +3 -4
  54. package/dist/core/runtime/kickoff.js +23 -13
  55. package/dist/core/runtime/node-read.d.ts +2 -0
  56. package/dist/core/runtime/node-read.js +11 -3
  57. package/dist/core/runtime/session-cycles.d.ts +6 -1
  58. package/dist/core/runtime/session-cycles.js +17 -11
  59. package/dist/core/runtime/session-visibility.d.ts +7 -12
  60. package/dist/core/runtime/session-visibility.js +7 -13
  61. package/dist/core/runtime/situational-live.js +2 -1
  62. package/dist/core/runtime/stop-guard.js +8 -4
  63. package/dist/core/runtime/warm-pool.d.ts +1 -1
  64. package/dist/core/runtime/warm-pool.js +6 -9
  65. package/dist/daemon/api/handlers/nodes.js +10 -7
  66. package/dist/daemon/human/finish.js +44 -4
  67. package/dist/daemon/messaging/node-message.d.ts +5 -0
  68. package/dist/daemon/messaging/node-message.js +6 -1
  69. package/dist/daemon/review/comment-notify.js +8 -0
  70. package/dist/daemon/review/deliver.js +2 -2
  71. package/dist/daemon/review/finish.js +1 -0
  72. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
  73. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
  74. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  75. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
  76. package/dist/pi-extensions/broker-local.d.ts +0 -2
  77. package/dist/pi-extensions/broker-local.js +0 -2
  78. package/dist/pi-extensions/canvas-context-intro.js +4 -3
  79. package/dist/pi-extensions/canvas-goal-capture.js +10 -6
  80. package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
  81. package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
  82. package/dist/pi-extensions/canvas-passive-context.js +23 -3
  83. package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
  84. package/dist/pi-extensions/canvas-review-boundary.js +11 -3
  85. package/dist/pi-extensions/canvas-stophook.js +3 -3
  86. package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
  87. package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
  88. package/dist/shared/generated-context.d.ts +57 -36
  89. package/dist/shared/generated-context.js +273 -174
  90. package/dist/shared/tool-groups.js +3 -2
  91. package/package.json +1 -1
  92. package/runtime.lock.json +2 -2
  93. package/scripts/postinstall.mjs +9 -0
@@ -1,16 +1,11 @@
1
- import type { BrokerSnapshot, WireSessionTreeNode } from './broker-protocol.js';
2
- /** customType of the crtr-authored boundary marker that opens a review
3
- * companion's visible transcript. Data payload: `{ reviewId, originNodeId,
4
- * targetFile }` — per-review facts that complement the inheritance prefix.
5
- *
6
- * The marker itself is a crtr-rendered divider shown to the person; everything
7
- * above it in the inherited session is context they cannot see.
8
- */
9
- export declare const REVIEW_BOUNDARY_CUSTOM_TYPE = "crtr-review-boundary";
1
+ import type { WireSessionTreeNode } from './broker-protocol.js';
2
+ import type { IdentifiedMessage } from './session-cycles.js';
3
+ import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
4
+ export { REVIEW_BOUNDARY_CUSTOM_TYPE };
10
5
  /** True when an entry is a durable review boundary, optionally for one review. */
11
6
  export declare function isReviewBoundary(entry: unknown, reviewId?: string): boolean;
12
7
  /**
13
- * Project snapshot messages to the visible window for a review companion.
8
+ * Project identified snapshot messages to the visible window for a review companion.
14
9
  *
15
10
  * A companion passes its binding's review id so the slice starts at ITS OWN
16
11
  * boundary marker: a node forked from another companion inherits that
@@ -27,9 +22,9 @@ export declare function isReviewBoundary(entry: unknown, reviewId?: string): boo
27
22
  * with a synthetic boundary while its retained tail remains visible. Every
28
23
  * other markerless shape fails closed with the synthetic boundary alone.
29
24
  */
30
- export declare function visibleMessages(messages: BrokerSnapshot['messages'], opts: {
25
+ export declare function visibleMessages(messages: IdentifiedMessage[], opts: {
31
26
  boundaryReviewId: string | undefined;
32
- }): BrokerSnapshot['messages'];
27
+ }): IdentifiedMessage[];
33
28
  /**
34
29
  * Project a session tree to the review companion's visible transcript boundary.
35
30
  *
@@ -9,14 +9,8 @@
9
9
  // Applied at the two BrokerSnapshot.messages producers (live broker and
10
10
  // dormant node read) so every presenter — attach, web node viewer, inspect — is
11
11
  // boundary-safe by construction with no presenter-side changes.
12
- /** customType of the crtr-authored boundary marker that opens a review
13
- * companion's visible transcript. Data payload: `{ reviewId, originNodeId,
14
- * targetFile }` — per-review facts that complement the inheritance prefix.
15
- *
16
- * The marker itself is a crtr-rendered divider shown to the person; everything
17
- * above it in the inherited session is context they cannot see.
18
- */
19
- export const REVIEW_BOUNDARY_CUSTOM_TYPE = 'crtr-review-boundary';
12
+ import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
13
+ export { REVIEW_BOUNDARY_CUSTOM_TYPE };
20
14
  /** True when an entry is a durable review boundary, optionally for one review. */
21
15
  export function isReviewBoundary(entry, reviewId) {
22
16
  if (entry === null || typeof entry !== 'object')
@@ -36,7 +30,7 @@ function syntheticBoundary(timestamp) {
36
30
  };
37
31
  }
38
32
  /**
39
- * Project snapshot messages to the visible window for a review companion.
33
+ * Project identified snapshot messages to the visible window for a review companion.
40
34
  *
41
35
  * A companion passes its binding's review id so the slice starts at ITS OWN
42
36
  * boundary marker: a node forked from another companion inherits that
@@ -54,16 +48,16 @@ function syntheticBoundary(timestamp) {
54
48
  * other markerless shape fails closed with the synthetic boundary alone.
55
49
  */
56
50
  export function visibleMessages(messages, opts) {
57
- const markerIndex = messages.findIndex((message) => isReviewBoundary(message, opts.boundaryReviewId));
51
+ const markerIndex = messages.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
58
52
  if (markerIndex !== -1)
59
53
  return messages.slice(markerIndex);
60
54
  if (opts.boundaryReviewId === undefined)
61
55
  return messages;
62
56
  const [first, ...retainedTail] = messages;
63
- if (first?.role === 'compactionSummary') {
64
- return [syntheticBoundary(first.timestamp), ...retainedTail];
57
+ if (first?.message.role === 'compactionSummary') {
58
+ return [{ id: '~boundary', message: syntheticBoundary(first.message.timestamp) }, ...retainedTail];
65
59
  }
66
- return [syntheticBoundary(first?.timestamp)];
60
+ return [{ id: '~boundary', message: syntheticBoundary(first?.message.timestamp) }];
67
61
  }
68
62
  /**
69
63
  * Project a session tree to the review companion's visible transcript boundary.
@@ -16,6 +16,7 @@
16
16
  // (`deliver-live.ts`), which the claim's bearings delivery also rides.
17
17
  import { getNode } from '../canvas/index.js';
18
18
  import { deliverCustomMessageLive } from './deliver-live.js';
19
+ import { formatCard } from '../../shared/generated-context.js';
19
20
  import { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from './situational-context.js';
20
21
  /** Write `text` to the node's sidecar and, when its broker is live, deliver the
21
22
  * rendered block into the running session for the NEXT turn.
@@ -34,7 +35,7 @@ export async function setSituationalContextLive(nodeId, text) {
34
35
  return; // blank text — appendSituationalContext no-ops, nothing to deliver
35
36
  await deliverCustomMessageLive(nodeId, {
36
37
  customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
37
- content: block,
38
+ content: formatCard('situational', {}, block),
38
39
  deliverAs: 'nextTurn',
39
40
  });
40
41
  }
@@ -23,16 +23,16 @@
23
23
  // final pushed. Re-prompt it to finish or escalate.
24
24
  import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
25
25
  import { activeBackgroundBashJobs } from '../bash-jobs.js';
26
- import { formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
26
+ import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
27
27
  import { readOutputRequest } from './structured-output.js';
28
28
  export { STALL_REPROMPT } from '../../shared/generated-context.js';
29
29
  /** Format the stop guard's reprompt for a valid structured-output request. */
30
30
  function formatValidStructuredOutputReprompt(schema) {
31
- return formatStructuredOutputReprompt(JSON.stringify(schema, null, 2));
31
+ return formatCard('stop-guard', { reason: 'structured-output' }, formatStructuredOutputReprompt(JSON.stringify(schema, null, 2)));
32
32
  }
33
33
  /** Format the stop guard's reprompt for an invalid structured-output request file. */
34
34
  function formatInvalidStructuredOutputReprompt(error) {
35
- return (`Structured-output request file is invalid and cannot be used: ${error}\n\n` +
35
+ return formatCard('stop-guard', { reason: 'structured-output' }, `Structured-output request file is invalid and cannot be used: ${error}\n\n` +
36
36
  `You have two options:\n` +
37
37
  `1. Remove the invalid file (the output-schema.json in your node directory has been corrupted)\n` +
38
38
  `2. Fix the corruption if you know the original request schema\n\n` +
@@ -96,5 +96,9 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
96
96
  || backgroundJobsRunning)
97
97
  return { action: 'allow', reason: 'awaiting' };
98
98
  // A terminal node with nothing live and no final pushed has stalled.
99
- return { action: 'reprompt', reason: 'stalled', message: STALL_REPROMPT };
99
+ return {
100
+ action: 'reprompt',
101
+ reason: 'stalled',
102
+ message: formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT),
103
+ };
100
104
  }
@@ -17,7 +17,7 @@ export interface WarmRequest {
17
17
  kind: string;
18
18
  mode: Mode;
19
19
  /** Where the node runs — already resolved by the create handler (pin >
20
- * spawner > profile home), never a raw caller directory. */
20
+ * spawner > launch cwd > profile home). */
21
21
  cwd: string;
22
22
  /** The RESOLVED profile id, or null for explicitly no profile. */
23
23
  profileId: string | null;
@@ -47,10 +47,9 @@
47
47
  // and at daemon start for the most recently used profiles, so the FIRST node
48
48
  // after sitting down is warm too.
49
49
  //
50
- // The pool does not fan out per directory: a node runs in its profile's home
51
- // (`resolveNodeCwd` in the create handler), so the cwd a spare freezes is a
52
- // function of its profile, and "which spare serves this create" is in practice
53
- // a question about the profile alone.
50
+ // A spare freezes the directory it was booted in, so a create launched from a
51
+ // different directory than the last one simply misses and cold-spawns — the
52
+ // pool serves the common case of returning to the same working directory.
54
53
  //
55
54
  // Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
56
55
  // which hides the pool, so a spare whose broker dies (every daemon handover
@@ -63,7 +62,6 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
63
62
  import { recordedPidLiveness } from '../canvas/pid.js';
64
63
  import { nowIso } from '../fs-utils.js';
65
64
  import { spawnChildPrepared } from './spawn.js';
66
- import { profileHome } from '../profiles/manifest.js';
67
65
  import { buildLaunchSpec } from './launch.js';
68
66
  import { setModelLive } from './model-swap.js';
69
67
  import { setSituationalContextLive } from './situational-live.js';
@@ -335,10 +333,9 @@ export function prewarmRecentRecipes() {
335
333
  if (seen.has(profileId ?? ''))
336
334
  continue;
337
335
  seen.add(profileId ?? '');
338
- // The profile's home is where its next node will land. A profile without
339
- // one (root, or a home since deleted) still gets a spare, keyed on the
340
- // directory its last root actually ran in.
341
- candidates.push({ cwd: profileHome(profileId) ?? row.cwd, profileId });
336
+ // A node lands in the directory its launch names, so the profile's last
337
+ // root predicts the next one better than the profile's home does.
338
+ candidates.push({ cwd: row.cwd, profileId });
342
339
  if (candidates.length >= PREWARM_PROFILES)
343
340
  break;
344
341
  }
@@ -98,14 +98,14 @@ function parseCreateBody(body) {
98
98
  }
99
99
  /** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
100
100
  * managed child inherits its spawner's cwd (which is what keeps a worktree
101
- * node's whole subtree inside that worktree); else the node runs in its
102
- * profile's home. The request's own `cwd` — the caller's directory — is only
103
- * the last resort, for a profile that has no home (notably root).
101
+ * node's whole subtree inside that worktree); else the launch's own `cwd`, the
102
+ * directory the create came from; else the profile's home.
104
103
  *
105
- * This is the profile→cwd direction: `cwd` selected the profile a moment ago,
106
- * and the profile now supplies where the node runs. Collapsing every create
107
- * under a profile onto one directory is also what keeps the warm pool to one
108
- * spare per profile instead of one per directory anyone happened to spawn from. */
104
+ * The launch directory beating the profile home is what lets one profile cover
105
+ * many working directories — a root started inside a planted instance runs in
106
+ * that instance while still taking its identity, purview, and memory from the
107
+ * profile covering it. The profile supplies a home only for a create that named
108
+ * no directory at all. */
109
109
  function resolveNodeCwd(req, parent, profileId, contextCwd) {
110
110
  if (req.pin_cwd !== undefined && req.pin_cwd !== '')
111
111
  return resolve(req.pin_cwd);
@@ -114,6 +114,8 @@ function resolveNodeCwd(req, parent, profileId, contextCwd) {
114
114
  if (spawner !== null)
115
115
  return spawner.cwd;
116
116
  }
117
+ if (req.cwd !== undefined && req.cwd !== '')
118
+ return resolve(req.cwd);
117
119
  return profileHome(profileId) ?? contextCwd;
118
120
  }
119
121
  /** Build the immediate spawn recipe. */
@@ -328,6 +330,7 @@ async function handleMessages(ctx) {
328
330
  const body = {
329
331
  node_id: id,
330
332
  messages: read.messages,
333
+ ...(read.messageIds === undefined ? {} : { message_ids: read.messageIds }),
331
334
  next_cursor: read.nextCursor,
332
335
  captured_at: nowIso(),
333
336
  };
@@ -9,7 +9,7 @@ import { pagePath, readTicketReplyRoute, responsePath } from '../../core/human/c
9
9
  import { readPageFeedback } from '../../core/human/feedback.js';
10
10
  import { pageDocumentSha256 } from '../../core/human/feedback-companion.js';
11
11
  import { appendInbox } from '../../core/feed/inbox.js';
12
- import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
12
+ import { FinalDeliveryError, pushFinal, pushUpdate } from '../../core/feed/feed.js';
13
13
  import { transition } from '../../core/runtime/lifecycle.js';
14
14
  import { emitEvent } from '../../core/events/emit.js';
15
15
  import { retireCompanion } from '../companion-retire.js';
@@ -80,15 +80,51 @@ function pageTitle(dir) {
80
80
  return undefined;
81
81
  }
82
82
  }
83
+ /** Person-authored response values, derived from the structured answer rather
84
+ * than display prose. Empty values have no words to attribute. */
85
+ function personWords(answer) {
86
+ if (answer.kind === 'acknowledgement')
87
+ return '';
88
+ const words = [];
89
+ for (const slot of answer.slots) {
90
+ if (slot.state !== 'answered')
91
+ continue;
92
+ if (slot.raw !== undefined) {
93
+ words.push(JSON.stringify(slot.raw));
94
+ continue;
95
+ }
96
+ if (slot.text !== undefined) {
97
+ if (slot.text.edited || slot.text.singleLine === true)
98
+ words.push(slot.text.value);
99
+ continue;
100
+ }
101
+ for (const group of slot.groups)
102
+ words.push(...group.picked.map((item) => item.label));
103
+ if (slot.freetext !== undefined)
104
+ words.push(slot.freetext);
105
+ words.push(...slot.comments.map((comment) => comment.text));
106
+ }
107
+ return words.filter((word) => word !== '').join('\n\n');
108
+ }
109
+ /** The answer push: the person's words alone when the page captured any, tagged
110
+ * so every reader attributes them to the person without re-deriving it from
111
+ * prose. A page that captured nothing stays an ordinary report carrying the
112
+ * full record, because there is nothing of the person's to attribute. */
83
113
  function renderPageAnswer(dir, result, resultPath) {
84
114
  let manifest;
85
115
  try {
86
116
  manifest = parsePage(dir);
87
117
  }
88
118
  catch {
89
- return renderCorruptPageAnswer(result, resultPath);
119
+ return { body: renderCorruptPageAnswer(result, resultPath) };
90
120
  }
91
- return renderAnswerText(describePageAnswer(manifest, result.responses, { completedAt: result.completedAt }), resultPath) + feedbackAddendum(dir);
121
+ const answer = describePageAnswer(manifest, result.responses, { completedAt: result.completedAt });
122
+ const rendered = renderAnswerText(answer, resultPath);
123
+ const addendum = feedbackAddendum(dir);
124
+ const words = personWords(answer);
125
+ return words === ''
126
+ ? { body: rendered + addendum }
127
+ : { body: words, disposition: 'human-answer', ...(addendum === '' ? {} : { feedback: addendum.trim() }) };
92
128
  }
93
129
  function deliveryFailed(error) {
94
130
  // A canonical final is committed inside its own write boundary and can never
@@ -198,6 +234,7 @@ export async function deliverTerminalResult(ticketId) {
198
234
  kind: 'message',
199
235
  label: subject,
200
236
  data: { body },
237
+ disposition: 'human-canceled',
201
238
  });
202
239
  }
203
240
  catch {
@@ -210,7 +247,10 @@ export async function deliverTerminalResult(ticketId) {
210
247
  return true;
211
248
  }
212
249
  if (result.kind === 'page') {
213
- await pushFinal(bridgeNodeId, renderPageAnswer(dir, result, responsePath(dir)));
250
+ const answer = renderPageAnswer(dir, result, responsePath(dir));
251
+ if (answer.feedback !== undefined)
252
+ await pushUpdate(bridgeNodeId, answer.feedback);
253
+ await pushFinal(bridgeNodeId, answer.body, answer.disposition === undefined ? undefined : { disposition: answer.disposition });
214
254
  }
215
255
  return true;
216
256
  }
@@ -4,6 +4,10 @@ export interface NodeMessageDelivery {
4
4
  via: 'engine' | 'inbox';
5
5
  revived: boolean;
6
6
  }
7
+ export interface RuntimeMessageCard {
8
+ kind: string;
9
+ facts: Record<string, string | number | boolean | undefined>;
10
+ }
7
11
  /**
8
12
  * Deliver one daemon-originated node message. Interactive messages use the live
9
13
  * broker when available; every other successful path is a durable inbox entry.
@@ -15,4 +19,5 @@ export declare function deliverNodeMessage(args: {
15
19
  from?: string | null;
16
20
  mode: NodeMessageMode;
17
21
  data?: Record<string, unknown>;
22
+ runtime?: RuntimeMessageCard;
18
23
  }): Promise<NodeMessageDelivery>;
@@ -7,6 +7,7 @@ import { isBrokerLive } from '../../core/runtime/model-swap.js';
7
7
  import { reviveNode } from '../../core/runtime/revive.js';
8
8
  import { hasNoNaturalCycle } from '../../core/runtime/revive-all.js';
9
9
  import { ReviewOperationError } from '../../core/review/types.js';
10
+ import { formatCard } from '../../shared/generated-context.js';
10
11
  function deliveryUnroutable(nodeId, reason) {
11
12
  throw new ReviewOperationError('delivery_unroutable', reason === 'origin_missing' ? 'message target was not found' : 'message target has no natural delivery cycle', { node_id: nodeId, reason });
12
13
  }
@@ -33,7 +34,11 @@ export async function deliverNodeMessage(args) {
33
34
  deliveryUnroutable(args.node_id, 'origin_missing');
34
35
  if (args.mode === 'interactive' && isBrokerLive(target)) {
35
36
  try {
36
- await deliverLive(args.node_id, args.body);
37
+ // Inbox delivery supplies the raw body as an entry in its outer inbox
38
+ // card; only live delivery needs this message's own envelope.
39
+ await deliverLive(args.node_id, args.runtime === undefined
40
+ ? args.body
41
+ : formatCard(args.runtime.kind, args.runtime.facts, args.body));
37
42
  // The broker acknowledgement is asynchronous to the SQLite guard. Recheck
38
43
  // before reporting an engine receipt so a final that committed during the
39
44
  // request is not represented as a routable delivery.
@@ -70,6 +70,14 @@ export async function notifyComment(args) {
70
70
  comment_id: args.comment.comment_id,
71
71
  verb: args.verb,
72
72
  },
73
+ runtime: {
74
+ kind: 'review-comment',
75
+ facts: {
76
+ review_id: args.review.review_id,
77
+ comment_id: args.comment.comment_id,
78
+ verb: args.verb,
79
+ },
80
+ },
73
81
  });
74
82
  }
75
83
  catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { FinalizationError, pushFinal, redeliverFinalReport } from '../../core/feed/feed.js';
2
- import { formatReviewApproval } from '../../shared/generated-context.js';
2
+ import { formatCard } from '../../shared/generated-context.js';
3
3
  import { deliverNodeMessage } from '../messaging/node-message.js';
4
4
  function approvalBody(review, resultPath) {
5
5
  const summary = review.changed
@@ -9,7 +9,7 @@ function approvalBody(review, resultPath) {
9
9
  }
10
10
  /** Render the immutable approval fact without replaying comment text. */
11
11
  export function renderApprovalMessage(review, resultPath) {
12
- return formatReviewApproval(approvalBody(review, resultPath));
12
+ return formatCard('review-approval', { review: review.review_id, file: review.file }, approvalBody(review, resultPath));
13
13
  }
14
14
  function recordedFailedFanout(review, reportBasename) {
15
15
  const error = review.delivery_error;
@@ -164,6 +164,7 @@ async function noteSubmitQueued(review) {
164
164
  mode: 'quiet',
165
165
  label: 'human review submitted — result to follow',
166
166
  data: { review_id: review.review_id },
167
+ runtime: { kind: 'review-queued', facts: { review_id: review.review_id } },
167
168
  body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
168
169
  + "You'll be notified with the result once the review completes. Nothing is needed from you now.",
169
170
  });
@@ -0,0 +1,96 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/pi-extensions/__tests__/canvas-goal-capture-envelope.test.ts
2
+ //
3
+ // The revive-kickoff exclusion in canvas-goal-capture. A fresh revive's kickoff
4
+ // arrives as a LAUNCH PROMPT — an ordinary interactive input event, not an
5
+ // extension injection — so without an exclusion it looks exactly like a bare
6
+ // root's first typed message: it would be written to context/initial-prompt.md
7
+ // as the node's mandate and would name the node from the runtime's own words.
8
+ //
9
+ // The exclusion used to key on the kickoff's opening sentence. It now keys on
10
+ // the <runtime kind="revive" …> envelope, and this file pins that the real
11
+ // builder's real bytes are still excluded — the two changes are one unit
12
+ // precisely because the envelope moves the bytes the old check read.
13
+ //
14
+ // The naming attempt is observed at the daemon socket: generateAndCommitInitialName
15
+ // starts by asking crtrd for the node's projection, so a request for a node id is
16
+ // proof that naming ran for it, and no request is proof that it did not.
17
+ import { test, before, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
20
+ import { createServer } from 'node:net';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import registerCanvasGoalCapture from '../canvas-goal-capture.js';
24
+ import { createNode } from '../../core/canvas/canvas.js';
25
+ import { closeDb } from '../../core/canvas/db.js';
26
+ import { apiSocketPath } from '../../core/canvas/paths.js';
27
+ import { buildReviveKickoff, drainBearings } from '../../core/runtime/kickoff.js';
28
+ let home;
29
+ let socket;
30
+ /** Every request line the extension's naming path sent to the daemon socket. */
31
+ const requests = [];
32
+ function node(id) {
33
+ return {
34
+ node_id: id,
35
+ name: id,
36
+ created: new Date().toISOString(),
37
+ cwd: '/tmp/work',
38
+ kind: 'general',
39
+ mode: 'base',
40
+ lifecycle: 'terminal',
41
+ status: 'active',
42
+ };
43
+ }
44
+ /** Register the extension and hand back its input handler. */
45
+ function handler() {
46
+ let captured;
47
+ registerCanvasGoalCapture({ on: (_event, fn) => { captured = fn; } });
48
+ assert.ok(captured !== undefined, 'the extension registers an input handler');
49
+ return captured;
50
+ }
51
+ function initialPromptPath(id) {
52
+ return join(home, 'nodes', id, 'context', 'initial-prompt.md');
53
+ }
54
+ async function waitForRequestMentioning(id) {
55
+ for (let i = 0; i < 200; i++) {
56
+ if (requests.some((line) => line.includes(id)))
57
+ return;
58
+ await new Promise((resolve) => setTimeout(resolve, 10));
59
+ }
60
+ throw new Error(`no daemon request mentioning ${id} arrived`);
61
+ }
62
+ before(async () => {
63
+ home = mkdtempSync(join(tmpdir(), 'crtr-goal-capture-'));
64
+ process.env['CRTR_HOME'] = home;
65
+ // A stub daemon: it records the request and hangs up, so the naming path fails
66
+ // fast (and silently, as it must) without any model call.
67
+ socket = createServer((connection) => {
68
+ connection.on('data', (chunk) => {
69
+ requests.push(chunk.toString('utf8'));
70
+ connection.destroy();
71
+ });
72
+ });
73
+ await new Promise((resolve) => socket.listen(apiSocketPath(), resolve));
74
+ });
75
+ after(async () => {
76
+ closeDb();
77
+ await new Promise((resolve) => socket.close(() => resolve()));
78
+ rmSync(home, { recursive: true, force: true });
79
+ delete process.env['CRTR_HOME'];
80
+ delete process.env['CRTR_NODE_ID'];
81
+ });
82
+ test('a fresh-revive kickoff seeds no mandate and no name; a human first message seeds both', async () => {
83
+ const revived = createNode(node('revived-node'));
84
+ const kickoff = buildReviveKickoff(revived, drainBearings(revived));
85
+ process.env['CRTR_NODE_ID'] = revived.node_id;
86
+ handler()({ type: 'input', text: kickoff, source: 'interactive' });
87
+ const bare = createNode(node('bare-root'));
88
+ process.env['CRTR_NODE_ID'] = bare.node_id;
89
+ handler()({ type: 'input', text: 'build me a receipt tracker', source: 'interactive' });
90
+ // The human message's naming call is the clock: both inputs ran through the
91
+ // same handler, so once its request has landed the revive's would have too.
92
+ await waitForRequestMentioning(bare.node_id);
93
+ assert.equal(existsSync(initialPromptPath(revived.node_id)), false, 'a fresh revive never overwrites the node mandate with the runtime kickoff');
94
+ assert.equal(requests.some((line) => line.includes(revived.node_id)), false, 'a fresh revive never names the node from the runtime kickoff');
95
+ assert.equal(readFileSync(initialPromptPath(bare.node_id), 'utf8').trim(), 'build me a receipt tracker', "a bare root's first typed message is still captured as its mandate");
96
+ });
@@ -409,7 +409,7 @@ test('stalled leaf (nothing live to await, no final) is still reprompted', async
409
409
  let shutdown = false;
410
410
  await settle(pi, stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
411
411
  assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
412
- assert.equal(pi.injected[0].content, STALL_REPROMPT, 'reprompt carries the stall nudge to push final / ask');
412
+ assert.equal(pi.injected[0].content, `<runtime kind="stop-guard" reason="stalled">${STALL_REPROMPT}</runtime>`, 'reprompt carries the stall nudge to push final / ask');
413
413
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
414
414
  assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
415
415
  assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
@@ -98,7 +98,7 @@ test('band crossing mid-run (tool calls present) → context nudge is a STEER',
98
98
  await pi.fire('turn_end', turnEnd([{ type: 'toolCall', id: 't1', name: 'bash', arguments: {} }], 'toolUse'), ctxAt(150_000));
99
99
  assert.equal(pi.steered.length, 1, 'exactly one steer delivered');
100
100
  assert.equal(pi.steered[0].deliverAs, 'steer');
101
- assert.match(pi.steered[0].content, /^\[crtr\] Context ~150k/);
101
+ assert.match(pi.steered[0].content, /^<runtime kind="context-nudge">Context ~150k/);
102
102
  assert.equal(pi.held.length, 0, 'nothing held for nextTurn');
103
103
  });
104
104
  test('band crossing on the FINAL message (no tool calls) → nudge is HELD as nextTurn, never a steer', async () => {
@@ -111,7 +111,7 @@ test('band crossing on the FINAL message (no tool calls) → nudge is HELD as ne
111
111
  assert.equal(pi.held.length, 1, 'nudge held for the next user message');
112
112
  assert.equal(pi.held[0].deliverAs, 'nextTurn');
113
113
  assert.equal(pi.held[0].customType, 'crtr-context-nudge');
114
- assert.match(pi.held[0].content, /^\[crtr\] Context ~150k/);
114
+ assert.match(pi.held[0].content, /^<runtime kind="context-nudge">Context ~150k/);
115
115
  assert.notEqual(pi.held[0].triggerTurn, true, 'must not trigger a turn');
116
116
  });
117
117
  test('a truncated tool-calling turn (stopReason length) still steers — the loop continues past failed calls', async () => {
@@ -1,5 +1,3 @@
1
- import { REVIVE_KICKOFF_SENTINEL } from '../shared/generated-context.js';
2
- export { REVIVE_KICKOFF_SENTINEL };
3
1
  /** Persist a bare root's first real interactive message without clobbering an
4
2
  * existing spawning mandate. This is broker-local file state, not canvas.db. */
5
3
  export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
@@ -7,11 +7,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { CRTR_DIR_NAME } from '../types.js';
10
- import { REVIVE_KICKOFF_SENTINEL } from '../shared/generated-context.js';
11
10
  import { brokerExtensionState, commitBrokerGeneratedName, } from '../core/runtime/broker/daemon-ops.js';
12
11
  import { publishNodeNamed } from '../core/runtime/broker/node-named.js';
13
12
  import { headlessName, headlessNameFromConversation, sanitizeSessionName, slugFromPrompt, } from '../core/runtime/naming.js';
14
- export { REVIVE_KICKOFF_SENTINEL };
15
13
  function contextDir(nodeId) {
16
14
  if (nodeId === '' || nodeId === '.' || nodeId === '..' || /[\\/\0]/u.test(nodeId)) {
17
15
  throw new TypeError('node_id must be a non-empty path segment');
@@ -26,7 +26,7 @@
26
26
  //
27
27
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
28
28
  // crouter's own tsc build without a dep on the pi packages.
29
- import { CONTEXT_INTRO_CUSTOM_TYPE } from '../shared/generated-context.js';
29
+ import { CONTEXT_INTRO_CUSTOM_TYPE, formatCard, parseCard } from '../shared/generated-context.js';
30
30
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
31
31
  import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker } from '../core/runtime/broker-extension-render.js';
32
32
  import { situationalContextBlock } from '../core/runtime/situational-context.js';
@@ -112,7 +112,8 @@ export function renderContextMessage(message, options, theme) {
112
112
  return [paint('dim', truncateToWidth(stub, w))];
113
113
  }
114
114
  const lines = [paint('customMessageLabel', truncateToWidth(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`, w)), ''];
115
- for (const raw of messageText(message).split('\n')) {
115
+ const body = parseCard({ role: 'custom', customType: message.customType, content: messageText(message) })?.body ?? messageText(message);
116
+ for (const raw of body.split('\n')) {
116
117
  for (const wrapped of wrapLine(raw, w))
117
118
  lines.push(paint('customMessageText', wrapped));
118
119
  }
@@ -172,7 +173,7 @@ export function registerCanvasContextIntro(pi) {
172
173
  : buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
173
174
  pi.sendMessage({
174
175
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
175
- content,
176
+ content: formatCard('bearings', {}, content),
176
177
  display: true,
177
178
  details: { nodeId },
178
179
  });
@@ -18,8 +18,9 @@
18
18
  // live-update the editor label. The first message may be a human's line OR
19
19
  // a delegated child's kickoff task — naming off the agent prompt is fine.
20
20
  //
21
- // Both skip extension-injected messages (inbox wakes, steering) and the
22
- // fresh-revive kickoff (its sentinel), so neither is mistaken for a first input.
21
+ // Both skip extension-injected messages (inbox wakes, steering) and every
22
+ // runtime-authored message (the fresh-revive kickoff and its siblings, each one
23
+ // a <runtime kind="…"> card), so neither is mistaken for a first input.
23
24
  //
24
25
  // Pure observation — it writes the goal file as a side effect and always lets
25
26
  // the message through unchanged (returns nothing ⇒ continue). Registered before
@@ -28,7 +29,8 @@
28
29
  //
29
30
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
30
31
  // crouter's own tsc build without a dep on the pi packages.
31
- import { captureGoalIfAbsent, generateAndCommitInitialName, REVIVE_KICKOFF_SENTINEL, } from './broker-local.js';
32
+ import { captureGoalIfAbsent, generateAndCommitInitialName, } from './broker-local.js';
33
+ import { parseCard } from '../shared/generated-context.js';
32
34
  /**
33
35
  * Register the goal-capture handler on `pi`.
34
36
  *
@@ -46,11 +48,13 @@ export function registerCanvasGoalCapture(pi) {
46
48
  if (text === '')
47
49
  return;
48
50
  // Never seed a mandate or a name from an extension-injected message (inbox
49
- // wakes, steering nudges) or a fresh-revive kickoff (the node is already
50
- // named). Both would otherwise masquerade as the node's first real input.
51
+ // wakes, steering nudges) or any runtime-authored message — a fresh-revive
52
+ // kickoff arrives as a launch prompt, not an injection, and would otherwise
53
+ // masquerade as the node's first real input: overwriting a bare root's
54
+ // mandate and renaming the node from the runtime's own words.
51
55
  if (event.source === 'extension')
52
56
  return;
53
- if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
57
+ if (parseCard({ role: 'user', content: text }) !== null)
54
58
  return;
55
59
  // Goal capture is bare-root only: a delegated child already had its goal
56
60
  // persisted at birth (writeGoal), so only a genuine human-typed prompt