@north-light/crouter 0.3.203 → 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 (97) 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 +66 -5
  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/companion-retire.d.ts +2 -2
  67. package/dist/daemon/companion-retire.js +16 -9
  68. package/dist/daemon/human/finish.d.ts +2 -2
  69. package/dist/daemon/human/finish.js +48 -8
  70. package/dist/daemon/human/sweep.js +3 -2
  71. package/dist/daemon/messaging/node-message.d.ts +5 -0
  72. package/dist/daemon/messaging/node-message.js +6 -1
  73. package/dist/daemon/review/comment-notify.js +8 -0
  74. package/dist/daemon/review/deliver.js +2 -2
  75. package/dist/daemon/review/finish.js +3 -2
  76. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
  77. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
  78. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  79. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
  80. package/dist/pi-extensions/broker-local.d.ts +0 -2
  81. package/dist/pi-extensions/broker-local.js +0 -2
  82. package/dist/pi-extensions/canvas-context-intro.js +4 -3
  83. package/dist/pi-extensions/canvas-goal-capture.js +10 -6
  84. package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
  85. package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
  86. package/dist/pi-extensions/canvas-passive-context.js +23 -3
  87. package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
  88. package/dist/pi-extensions/canvas-review-boundary.js +11 -3
  89. package/dist/pi-extensions/canvas-stophook.js +3 -3
  90. package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
  91. package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
  92. package/dist/shared/generated-context.d.ts +57 -36
  93. package/dist/shared/generated-context.js +273 -174
  94. package/dist/shared/tool-groups.js +3 -2
  95. package/package.json +1 -1
  96. package/runtime.lock.json +2 -2
  97. package/scripts/postinstall.mjs +9 -0
@@ -1,7 +1,6 @@
1
1
  // projection.ts — the surface-neutral reducer that turns a broker snapshot and
2
2
  // its live frames into one ordered transcript model. Renderers retain their own
3
3
  // folding and presentation; this module owns only wire-state reconciliation.
4
- import { isInboxDigest } from '../../shared/generated-context.js';
5
4
  import { normalizeToolGroupSummary, normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
6
5
  import { DEFAULT_WORKING_ACTIVITY } from '../../shared/working-activity.js';
7
6
  export function initialConvState() {
@@ -32,34 +31,6 @@ export function restoreConvState(entry) {
32
31
  toolGroupSummaryDetails: normalizeToolGroupSummaryRecord(entry.toolGroupSummaries),
33
32
  };
34
33
  }
35
- /** Plain text of a user message (string content or concatenated text blocks),
36
- * for inbox-digest detection. */
37
- function userMessageText(m) {
38
- const content = m.content;
39
- if (typeof content === 'string')
40
- return content;
41
- if (Array.isArray(content)) {
42
- return content
43
- .filter((b) => !!b && b.type === 'text')
44
- .map((b) => (typeof b.text === 'string' ? b.text : ''))
45
- .join('');
46
- }
47
- return '';
48
- }
49
- /** Tag a `role:'user'` message as inbox-origin when its text matches the
50
- * canvas-inbox-watcher coalesce digest; other messages pass through unchanged.
51
- * The broker relays pi AgentMessages verbatim and never carries `origin` on the
52
- * wire, so this is the sole writer of `origin:'inbox'` — recognized from the
53
- * digest text. (Idempotent: a message already tagged is left as-is.) */
54
- function tagInboxOrigin(m) {
55
- if (roleOf(m) !== 'user')
56
- return m;
57
- if (m.origin)
58
- return m;
59
- if (!isInboxDigest(userMessageText(m)))
60
- return m;
61
- return { ...m, origin: 'inbox' };
62
- }
63
34
  /** An empty assistant message shaped so the next `message_update`/`message_end`
64
35
  * (which replace, not append — see `reduce`'s `message_update` case) has a
65
36
  * slot to land on. */
@@ -85,7 +56,7 @@ function placeholderAssistant() {
85
56
  * first `message_update` REPLACES that message wholesale, so no duplicate or
86
57
  * dropped turn results. */
87
58
  export function applySnapshot(snapshot, previous) {
88
- const messages = snapshot.messages.map(tagInboxOrigin);
59
+ const messages = snapshot.messages;
89
60
  const toolGroupSummaryDetails = snapshotToolGroupSummaries(snapshot);
90
61
  const toolGroupSummaries = summaryTexts(toolGroupSummaryDetails);
91
62
  const isStreaming = snapshot.state?.isStreaming === true;
@@ -263,14 +234,13 @@ export function reduce(state, event) {
263
234
  executingToolIds: state.executingToolIds.size ? new Set() : state.executingToolIds,
264
235
  };
265
236
  case 'message_start': {
266
- // User messages arrive complete on message_start (no streaming deltas), so
267
- // tagging here is sufficient — update/end only mutate the assistant message.
268
- const tagged = tagInboxOrigin(event.message);
269
- const messages = [...state.messages, tagged];
270
- const isAssistant = roleOf(tagged) === 'assistant';
237
+ // User messages arrive complete on message_start (no streaming deltas).
238
+ const message = event.message;
239
+ const messages = [...state.messages, message];
240
+ const isAssistant = roleOf(message) === 'assistant';
271
241
  let partialToolResults = state.partialToolResults;
272
- if (roleOf(tagged) === 'toolResult') {
273
- const id = tagged.toolCallId;
242
+ if (roleOf(message) === 'toolResult') {
243
+ const id = message.toolCallId;
274
244
  if (typeof id === 'string' && partialToolResults.has(id)) {
275
245
  const next = new Map(partialToolResults);
276
246
  next.delete(id);
@@ -71,7 +71,7 @@ function nodeNewParams() {
71
71
  { kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message. Required unless --no-kickoff is given.' },
72
72
  { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind. The <kinds> list below names every top-level installable kind.' },
73
73
  { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
74
- { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the profile home for a root (`crtr profile show <profile>`).' },
74
+ { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the directory this command runs in for a root.' },
75
75
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name. Defaults to the kind.' },
76
76
  { kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
77
77
  { kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
@@ -31,7 +31,7 @@ const addLeaf = defineLeaf({
31
31
  name: 'home',
32
32
  type: 'bool',
33
33
  required: false,
34
- constraint: "Also make this directory the profile's home — where every node under it is pinned unless the create passes an explicit --cwd. Defaults to the first project dir; pass this on an already-listed dir to re-point it.",
34
+ constraint: "Also make this directory the profile's home — where a node under it runs when its create names no directory. Defaults to the first project dir; pass this on an already-listed dir to re-point it.",
35
35
  },
36
36
  ],
37
37
  output: [
@@ -22,7 +22,7 @@ export const showLeaf = defineLeaf({
22
22
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
23
23
  { name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
24
24
  { name: 'projects', type: 'string[]', required: true, constraint: 'Absolute project directories, in manifest order.' },
25
- { name: 'home', type: 'string | null', required: true, constraint: 'The directory every node under this profile is pinned to unless its create passes an explicit --cwd. Defaults to the first project; re-point it with `profile project add --home`. Null when the profile owns no projects (the root profile), in which case nodes fall back to the creating directory.' },
25
+ { name: 'home', type: 'string | null', required: true, constraint: 'Where a node under this profile runs when its create names no directory at all — an explicit --cwd and the directory a root is launched from both win over it. Defaults to the first project; re-point it with `profile project add --home`. Null when the profile owns no projects (the root profile).' },
26
26
  { name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
27
27
  { name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
28
28
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
@@ -30,7 +30,7 @@ export function registerProfile() {
30
30
  help: {
31
31
  name: 'profile',
32
32
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
33
- model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — and every node created under the profile is pinned there, so cwd selects the profile and the profile then supplies where its nodes run. Only `node new --cwd` (or inheriting a spawner already elsewhere, notably inside a managed worktree) overrides that; the root profile has no home, so nodes under it stay in the directory that created them. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
33
+ model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
34
34
  },
35
35
  children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
36
36
  });
@@ -17,6 +17,7 @@ import { reportsDir } from '../canvas/paths.js';
17
17
  import { closeDb } from '../canvas/db.js';
18
18
  import { appendInbox, coalesce, finalizeInboxEntry } from '../feed/inbox.js';
19
19
  import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
20
+ import { parseCard } from '../../shared/generated-context.js';
20
21
  // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
21
22
  // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the watcher delivers in tens of
22
23
  // milliseconds, not seconds — exactly the resolve+seed tick, read tick, and
@@ -49,6 +50,11 @@ function makeFakePi() {
49
50
  };
50
51
  }
51
52
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
53
+ /** The text of a batch that must coalesce into exactly one delivery. */
54
+ function onlyDelivery(deliveries) {
55
+ assert.equal(deliveries.length, 1, 'expected a single delivery');
56
+ return deliveries[0].text;
57
+ }
52
58
  // Register the watcher, then fire session_start — the real lifecycle gate
53
59
  // that arms its poll interval and initial tick (see canvas-inbox-watcher.ts).
54
60
  // registerCanvasInboxWatcher() alone only wires up handlers; without this the
@@ -75,7 +81,7 @@ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
75
81
  await wait(stepMs);
76
82
  }
77
83
  }
78
- test('coalesce inlines short report bodies with a trailing artifact pointer and only hints for remaining canonical refs', () => {
84
+ test('coalesce renders one inbox card whose entries inline short report bodies and carry their refs', () => {
79
85
  freshNode('child');
80
86
  const child = createNode({
81
87
  node_id: 'child',
@@ -90,38 +96,83 @@ test('coalesce inlines short report bodies with a trailing artifact pointer and
90
96
  const reportPath = join(reportsDir(child.node_id), 'short-update.md');
91
97
  mkdirSync(reportsDir(child.node_id), { recursive: true });
92
98
  writeFileSync(reportPath, '---\nnode: child\nkind: update\n---\nshort report body', 'utf8');
93
- const digest = coalesce([
99
+ const digest = onlyDelivery(coalesce([
94
100
  finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'update', label: 'short report', ref: 'child:reports/short-update.md' }),
101
+ ]));
102
+ // The envelope is the whole message: a reader that finds it anywhere else
103
+ // attributes the digest to the person.
104
+ assert.equal(digest.startsWith('<runtime kind="inbox"'), true);
105
+ assert.equal(digest.endsWith('</runtime>'), true);
106
+ const card = parseCard({ role: 'user', content: digest });
107
+ assert.notEqual(card, null);
108
+ assert.equal(card.kind, 'inbox');
109
+ assert.deepEqual({ ...card.facts }, { senders: '1', updates: '1' });
110
+ assert.equal(card.senders.length, 1);
111
+ assert.equal(card.senders[0].id, 'child');
112
+ // The label is NOT reprinted above the body it duplicates.
113
+ assert.deepEqual(card.senders[0].entries, [
114
+ { kind: 'update', disposition: 'report', body: 'short report body', ref: 'child:reports/short-update.md' },
95
115
  ]);
96
- // The label is NOT reprinted above the body it duplicates; the path trails it.
97
- assert.doesNotMatch(digest, /\[update\] short report/);
98
- assert.match(digest, /\[update\]\n {4}short report body\n/);
99
- assert.match(digest, new RegExp(`\\(report: ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
100
- assert.doesNotMatch(digest, /Dereference a ref with/);
101
- const guidance = 'Dereference a ref with `crtr canvas history read <ref>`.';
102
- const canonical = coalesce([
103
- finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'final', label: 'done', ref: 'child:reports/final.md' }),
116
+ assert.equal(reportPath.endsWith('short-update.md'), true);
117
+ // A report too large to inline (here: absent) keeps its label and its ref.
118
+ const canonical = parseCard({
119
+ role: 'user',
120
+ content: onlyDelivery(coalesce([
121
+ finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'final', label: 'done', ref: 'child:reports/final.md' }),
122
+ ])),
123
+ });
124
+ assert.deepEqual(canonical.senders[0].entries, [
125
+ { kind: 'final', disposition: 'report', body: 'done', ref: 'child:reports/final.md' },
104
126
  ]);
105
- assert.equal(canonical.endsWith(`\n\n${guidance}`), true);
106
- const spilled = coalesce([
107
- finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'message', label: 'long', ref: '/tmp/message.md', data: { body: 'long body' } }),
127
+ assert.equal(canonical.senders[0].finished, true);
128
+ const spilled = parseCard({
129
+ role: 'user',
130
+ content: onlyDelivery(coalesce([
131
+ finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'message', label: 'long', ref: '/tmp/message.md', data: { body: 'long body' } }),
132
+ ])),
133
+ });
134
+ assert.deepEqual(spilled.senders[0].entries, [
135
+ { kind: 'message', disposition: 'report', body: 'long body', ref: '/tmp/message.md' },
108
136
  ]);
109
- assert.doesNotMatch(spilled, /Dereference a ref with/);
110
137
  closeDb();
111
138
  });
112
139
  test('a human message coalesces verbatim — a conversation turn, not a digest', () => {
113
140
  // A human typing directly into a node's box is a CONVERSATION TURN, so the
114
- // dormant wake path must read identically to the live path: no "From … — N
115
- // updates" header, no `[message]` kind tag. Every other sender keeps framing.
141
+ // dormant wake path must read identically to the live path: no card, no
142
+ // `<from>` section. Every other sender is digest content.
116
143
  const body = 'first line\n\nsecond paragraph';
117
- const human = coalesce([
144
+ const human = onlyDelivery(coalesce([
118
145
  finalizeInboxEntry({ from: 'human', tier: 'normal', kind: 'message', label: 'first line', data: { body } }),
119
- ]);
146
+ ]));
120
147
  assert.equal(human, body);
121
- const agent = coalesce([
148
+ const agent = onlyDelivery(coalesce([
122
149
  finalizeInboxEntry({ from: 'mqifzplr-0753bde3', tier: 'normal', kind: 'message', label: 'go', data: { body: 'go' } }),
123
- ]);
124
- assert.match(agent, /^From mqifzplr-0753bde3 — 1 update:/);
150
+ ]));
151
+ assert.equal(agent.startsWith('<runtime kind="inbox"'), true);
152
+ assert.match(agent, /<from id="mqifzplr-0753bde3" updates="1">/);
153
+ });
154
+ test('a mixed batch splits: human words verbatim, node reports in one card, order preserved', () => {
155
+ // Concatenating the two into one message left `parseCard` unable to see the
156
+ // envelope, so the whole delivery read as the person's words.
157
+ const humanEntry = (body) => finalizeInboxEntry({ from: 'human', tier: 'normal', kind: 'message', label: body, data: { body } });
158
+ const nodeEntry = (from, body) => finalizeInboxEntry({ from, tier: 'normal', kind: 'update', label: body, data: { body } });
159
+ for (const [name, entries] of [
160
+ ['human first', [humanEntry('do the thing'), nodeEntry('mqifzplr-0753bde3', 'phase one done')]],
161
+ ['node first', [nodeEntry('mqifzplr-0753bde3', 'phase one done'), humanEntry('do the thing')]],
162
+ ]) {
163
+ const deliveries = coalesce([...entries]);
164
+ assert.equal(deliveries.length, 2, name);
165
+ // The card lands where its first sender fell.
166
+ assert.deepEqual(deliveries.map((d) => d.kind), name === 'human first' ? ['human', 'card'] : ['card', 'human'], name);
167
+ const human = deliveries.find((d) => d.kind === 'human');
168
+ assert.equal(human.text, 'do the thing', name);
169
+ assert.doesNotMatch(human.text, /<runtime|<from /, name);
170
+ const card = deliveries.find((d) => d.kind === 'card');
171
+ const parsed = parseCard({ role: 'user', content: card.text });
172
+ assert.notEqual(parsed, null, name);
173
+ assert.deepEqual(parsed.senders.map((sender) => sender.id), ['mqifzplr-0753bde3'], name);
174
+ assert.deepEqual(card.entries.map((entry) => entry.from), ['mqifzplr-0753bde3'], name);
175
+ }
125
176
  });
126
177
  let origTick;
127
178
  let origDebounce;
@@ -174,6 +225,22 @@ describe('canvas inbox watcher — finished-node delivery', () => {
174
225
  assert.equal(pi.injected.length, 1, 'one coalesced injection');
175
226
  assert.equal(pi.injected[0].deliverAs, 'steer', 'a finished node steers, not follows up');
176
227
  });
228
+ test('a mixed batch reaches pi as separate messages: the person verbatim, the card whole', async () => {
229
+ freshNode('node-mixed');
230
+ const pi = makeFakePi();
231
+ disposers.push(registerWatcher(pi));
232
+ await wait(TICK_MS + 100);
233
+ appendInbox('node-mixed', { from: 'human', tier: 'normal', kind: 'message', label: 'do the thing', data: { body: 'do the thing' } });
234
+ appendInbox('node-mixed', { from: 'child-9', tier: 'normal', kind: 'update', label: 'phase one done' });
235
+ // One visible send per cycle, so the second part lands a tick later.
236
+ await waitFor(() => pi.injected.length >= 2);
237
+ assert.equal(pi.injected.length, 2, 'the batch split into two visible messages');
238
+ assert.equal(pi.injected[0].content, 'do the thing', 'the person\'s words arrive verbatim');
239
+ assert.equal(parseCard({ role: 'user', content: pi.injected[0].content }), null, 'no envelope rides the human message');
240
+ const card = parseCard({ role: 'user', content: pi.injected[1].content });
241
+ assert.notEqual(card, null, 'the card message is a whole envelope');
242
+ assert.deepEqual(card.senders.map((sender) => sender.id), ['child-9']);
243
+ });
177
244
  test('mid-stream: a routine update still follows up', async () => {
178
245
  freshNode('node-update');
179
246
  const pi = makeFakePi();
@@ -7,15 +7,20 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'no
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createApiServer } from '../../daemon/api/server.js';
10
- import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
10
+ import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket, settleFeedbackCompanion } from '../../daemon/human/finish.js';
11
11
  import { humanSend } from '../../commands/human/prompts.js';
12
12
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
13
- import { readInboxSince } from '../feed/inbox.js';
13
+ import { coalesce, readInboxSince } from '../feed/inbox.js';
14
+ import { createPageFeedbackComment } from '../human/feedback.js';
14
15
  import { resolveRef } from '../canvas/history.js';
16
+ import { transition } from '../runtime/lifecycle.js';
17
+ import { reviveNode } from '../runtime/revive.js';
15
18
  import { apiSocketPath } from '../canvas/paths.js';
16
19
  import { spawnNode } from '../runtime/nodes.js';
17
20
  import { bindTicketReplyRoute } from '../human/convention.js';
18
21
  import { ticketDir } from '../human/root.js';
22
+ import { pageFeedbackPath } from '../human/feedback.js';
23
+ import { parseCard } from '../../shared/generated-context.js';
19
24
  import { readTicketResult, submitPage } from '../human/tickets.js';
20
25
  let home;
21
26
  let priorHome;
@@ -120,7 +125,7 @@ describe('enqueue is producer-only', () => {
120
125
  });
121
126
  });
122
127
  describe('terminal ticket delivery', () => {
123
- test('resolvePageTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
128
+ test("resolvePageTicket pushFinals the person's words once and wakes the subscribed parent", async () => {
124
129
  const { parent, bridge } = bridgeUnder();
125
130
  const dir = submitBridgePage(bridge);
126
131
  const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
@@ -130,8 +135,34 @@ describe('terminal ticket delivery', () => {
130
135
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
131
136
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
132
137
  const body = reportBody(finals[0].ref);
133
- assert.ok(body.includes('“yes, ship”'), 'the readable slot answer is inlined in the report');
134
- assert.ok(body.includes(join(dir, 'response.json')));
138
+ assert.equal(body, 'yes, ship');
139
+ assert.equal(finals[0].disposition, 'human-answer');
140
+ assert.ok(!body.includes(join(dir, 'response.json')));
141
+ });
142
+ test('settlement delivers feedback outside the human-answer entry', async () => {
143
+ const { parent, bridge } = bridgeUnder();
144
+ const dir = submitBridgePage(bridge);
145
+ writeFileSync(pageFeedbackPath(dir), JSON.stringify({
146
+ schema: 'crtr.page-feedback/v1',
147
+ companionNodeId: 'feedback-companion',
148
+ firstCommentedSha256: 'stale-page-hash',
149
+ comments: [],
150
+ }));
151
+ await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
152
+ closeDb();
153
+ const entries = readInboxSince(parent).filter((entry) => entry.from === bridge);
154
+ const final = entries.find((entry) => entry.kind === 'final');
155
+ const feedback = entries.find((entry) => entry.kind === 'update');
156
+ assert.ok(final);
157
+ assert.ok(feedback);
158
+ assert.equal(final.disposition, 'human-answer');
159
+ assert.match(reportBody(feedback.ref), /The page changed during feedback/);
160
+ const deliveries = coalesce(entries);
161
+ assert.equal(deliveries.length, 1);
162
+ const card = parseCard({ role: 'user', content: deliveries[0].text });
163
+ assert.ok(card);
164
+ const answer = card.senders.flatMap((sender) => sender.entries).find((entry) => entry.disposition === 'human-answer');
165
+ assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: 'yes, ship', ref: final.ref });
135
166
  });
136
167
  test('a replay against a terminal bridge is a no-op', async () => {
137
168
  const { parent, bridge } = bridgeUnder();
@@ -142,6 +173,36 @@ describe('terminal ticket delivery', () => {
142
173
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
143
174
  assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
144
175
  });
176
+ test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
177
+ const { bridge } = bridgeUnder();
178
+ const dir = submitBridgePage(bridge);
179
+ const companion = spawnNode({ kind: 'general', parent: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
180
+ createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
181
+ transition(companion, 'finish');
182
+ await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
183
+ const finalized = getNode(companion);
184
+ assert.equal(finalized.status, 'done');
185
+ assert.equal(finalized.intent, 'done');
186
+ assert.ok(finalized.final_report);
187
+ assert.ok(finalized.finalized_at);
188
+ const finalReport = finalized.final_report;
189
+ const finalizedAt = finalized.finalized_at;
190
+ await deliverTerminalResult(bridge);
191
+ assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
192
+ assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
193
+ assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
194
+ const { bridge: dismissedBridge } = bridgeUnder();
195
+ const dismissedDir = submitBridgePage(dismissedBridge);
196
+ const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
197
+ createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
198
+ await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
199
+ assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
200
+ assert.equal(getNode(dismissedCompanion)?.final_report, null);
201
+ const dismissedResult = readTicketResult(dismissedDir);
202
+ await settleFeedbackCompanion(dismissedBridge, dismissedResult.kind === 'canceled' ? 'canceled' : 'approved');
203
+ assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
204
+ assert.equal(getNode(dismissedCompanion)?.final_report, null);
205
+ });
145
206
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
146
207
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
147
208
  const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
@@ -89,7 +89,8 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
89
89
  const sessionFile = seed.getSessionFile();
90
90
  assert.ok(sessionFile, 'seed session persisted a .jsonl');
91
91
  // The persisted, ordered history a dormant reader / crouter-web normalizer sees.
92
- const persisted = SessionManager.open(sessionFile).buildSessionContext().messages;
92
+ const persistedManager = SessionManager.open(sessionFile);
93
+ const persisted = persistedManager.buildSessionContext().messages;
93
94
  assert.equal(persisted.length, 5, 'disk holds all five messages incl. the errored attempt');
94
95
  // 2. Revive: the broker resumes the closed session via the SERVICES path.
95
96
  const { session } = await buildBrokerSession(realEngine, {
@@ -113,9 +114,11 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
113
114
  // a turn the .jsonl still holds. The fix serves the persisted history (5),
114
115
  // byte-identical to the normalizer.
115
116
  const snap = snapshotMessages(session);
117
+ assert.ok(snap);
116
118
  assert.equal(snap.length, persisted.length, 'snapshot presents the COMPLETE persisted history (omit-on-revive regression)');
117
119
  assert.notEqual(snap.length, session.messages.length, 'snapshot does NOT echo the sliced live array');
118
- assert.deepEqual(snap, persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
120
+ assert.deepEqual(snap.map(({ message }) => message), persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
121
+ assert.deepEqual(snap.map(({ id }) => id), persistedManager.getEntries().map(({ id }) => id), 'snapshot ids stay aligned to the persisted entry that projected each message');
119
122
  }
120
123
  finally {
121
124
  session.dispose();
@@ -486,10 +486,13 @@ test('the real inbox watcher holds a deferred entry alone, then delivers it TOGE
486
486
  label: 'go now',
487
487
  data: { body: 'go now' },
488
488
  });
489
- await wait(SETTLE_MS);
490
- assert.equal(pi.injected.length, 1, 'both entries are delivered together in ONE coalesced turn once a non-deferred entry joins the batch');
491
- assert.match(pi.injected[0].content, /deferred note/, 'the previously-held deferred entry rides along — not lost');
492
- assert.match(pi.injected[0].content, /go now/, 'the new normal entry is in the same digest — not duplicated into a second turn');
489
+ await wait(SETTLE_MS + TICK_MS * 2);
490
+ // Two direct human entries are two verbatim deliveries — one message per
491
+ // entry, physical order preserved, one visible send per watcher cycle.
492
+ assert.equal(pi.injected.length, 2, 'both entries are delivered once a non-deferred entry joins the batch — each human entry as its own verbatim message');
493
+ assert.match(pi.injected[0].content, /deferred note/, 'the previously-held deferred entry is delivered first — not lost');
494
+ assert.doesNotMatch(pi.injected[0].content, /go now/, 'the deliveries are separate messages, not one concatenation');
495
+ assert.match(pi.injected[1].content, /go now/, 'the new normal entry follows in its own message — not duplicated');
493
496
  assert.equal(pi.injected[0].deliverAs, undefined, 'idle delivery triggers a fresh turn, no deliverAs');
494
497
  // Idle-path delivery hands off on the void send but only COMMITS the
495
498
  // durable cursor at agent_settled (never on the fire-and-forget send
@@ -59,6 +59,7 @@ import { subscribe } from '../../canvas/canvas.js';
59
59
  import { transition } from '../../runtime/lifecycle.js';
60
60
  import { pushFinal } from '../../feed/feed.js';
61
61
  import { evaluateStop, STALL_REPROMPT } from '../../runtime/stop-guard.js';
62
+ import { formatCard } from '../../../shared/generated-context.js';
62
63
  import { personaDrift, commitPersonaAck } from '../../runtime/persona.js';
63
64
  // A pid reaped (dead) by the time spawnSync returns — a released broker's exited
64
65
  // process (the headless analog of a closed pane).
@@ -228,7 +229,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
228
229
  const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
229
230
  assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
230
231
  assert.equal(stop.reason, 'stalled', 'classified stalled');
231
- assert.equal(stop.action === 'reprompt' ? stop.message : '', STALL_REPROMPT, 'the stall reprompt nudge');
232
+ assert.equal(stop.action === 'reprompt' ? stop.message : '', formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT), 'the stall reprompt nudge, delivered as a whole-message stop-guard envelope');
232
233
  }
233
234
  // ===================================================================
234
235
  // S10 — B YIELDS. The real plain `node yield` verb sets intent=refresh
@@ -121,8 +121,11 @@ test('the ticket resolve route pushFinals the readable answer to the parent', as
121
121
  assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
122
122
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
123
123
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
124
+ // A typed answer's final is purely the person's words, attributed by the
125
+ // human-answer disposition — no record path, no "Answered …" trailer.
126
+ assert.equal(finals[0].disposition, 'human-answer');
124
127
  const body = reportBody(finals[0].ref);
125
128
  assert.ok(body.includes('yes, ship'));
126
- assert.ok(body.includes(join(dir, 'response.json')));
129
+ assert.ok(!body.includes(join(dir, 'response.json')));
127
130
  assert.ok(!body.includes('humanloop.response/v2'));
128
131
  });
@@ -39,7 +39,8 @@ import { closeDb } from '../../canvas/db.js';
39
39
  import { buildPiArgv } from '../../runtime/launch.js';
40
40
  import { headlessBrokerHost } from '../../runtime/host.js';
41
41
  import { resumeArgs, reviveNode } from '../../runtime/revive.js';
42
- import { REVIVE_KICKOFF_SENTINEL, readGoal, writeGoal } from '../../runtime/kickoff.js';
42
+ import { readGoal, writeGoal } from '../../runtime/kickoff.js';
43
+ import { parseCard } from '../../../shared/generated-context.js';
43
44
  import { seedRoadmap } from '../../runtime/roadmap.js';
44
45
  import { InputError } from '../../io.js';
45
46
  import { createHarness } from '../helpers/harness.js';
@@ -59,6 +60,10 @@ function node(id, over = {}) {
59
60
  ...over,
60
61
  };
61
62
  }
63
+ /** A launch prompt is the fresh-revive kickoff when it carries that card kind. */
64
+ function isReviveKickoff(arg) {
65
+ return parseCard({ role: 'user', content: arg })?.kind === 'revive';
66
+ }
62
67
  /** A pid that is guaranteed dead — a crashed broker the guard must NOT see as up. */
63
68
  function deadPid() {
64
69
  const r = spawnSync('true', [], { stdio: 'ignore' });
@@ -487,7 +492,7 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
487
492
  createNode(node('bare-unstarted', { cwd: home, pi_session_id: 'uuid-a', pi_session_file: missing }));
488
493
  reviveNode('bare-unstarted', { resume: true });
489
494
  const bare = launches[0];
490
- assert.equal(bare.some((a) => a.startsWith(REVIVE_KICKOFF_SENTINEL)), false, 'an unstarted node is never told it lost context it never had');
495
+ assert.equal(bare.some((a) => isReviveKickoff(a)), false, 'an unstarted node is never told it lost context it never had');
491
496
  assert.deepEqual(bare, buildPiArgv(getNode('bare-unstarted'), {}).argv, 'a promptless boot — the argv carries no trailing prompt positional at all');
492
497
  // (2) Spawned with a mandate whose first turn never persisted: replay it.
493
498
  createNode(node('mandate-unstarted', { cwd: home, pi_session_id: 'uuid-b', pi_session_file: missing }));
@@ -498,7 +503,7 @@ test('an unstarted node relaunches as a birth, not a "revived fresh" kickoff', (
498
503
  createNode(node('progressed', { cwd: home, pi_session_id: 'uuid-c', pi_session_file: missing }));
499
504
  seedRoadmap('progressed', { goal: 'ship it' });
500
505
  reviveNode('progressed', { resume: true });
501
- assert.equal(launches[2].at(-1).startsWith(REVIVE_KICKOFF_SENTINEL), true, 'a node with a roadmap still rebuilds from the kickoff');
506
+ assert.equal(isReviveKickoff(launches[2].at(-1)), true, 'a node with a roadmap still rebuilds from the kickoff');
502
507
  }
503
508
  finally {
504
509
  headlessBrokerHost.launch = originalLaunch;
@@ -75,15 +75,17 @@ test('cycleAwareMessages: a compacted prior cycle replays via buildSessionContex
75
75
  // Minimal fake mirroring the real SDK's SessionManager just enough to drive
76
76
  // cycleAwareMessages: buildSessionContext() plus the tree accessors it reads.
77
77
  const out = cycleAwareMessages(cycleManager(entries, 'm4'));
78
- const summaries = out.filter((m) => m.role === 'compactionSummary');
78
+ assert.ok(out);
79
+ const summaries = out.filter(({ message }) => message.role === 'compactionSummary');
79
80
  assert.equal(summaries.length, 1, 'exactly one compactionSummary message from the replayed prior cycle');
80
- const texts = out.map((m) => (typeof m.content === 'string' ? m.content : ''));
81
+ const texts = out.map(({ message }) => (typeof message.content === 'string' ? message.content : ''));
81
82
  assert.ok(!texts.some((t) => t.includes('pre-compaction message')), 'the pre-compaction raw message (m1) must NOT appear');
82
83
  assert.ok(texts.some((t) => t.includes('kept message')), 'the kept message (m2) DOES appear');
83
84
  assert.ok(texts.some((t) => t.includes('cycle 0 leaf at yield')), 'the post-compaction message (m3) DOES appear');
84
85
  assert.ok(texts.some((t) => t.includes('cycle 1 live message')), 'the live cycle (m4) DOES appear');
85
- const dividers = out.filter((m) => m.customType === 'crtr-cycle-divider');
86
+ const dividers = out.filter(({ message }) => message.customType === 'crtr-cycle-divider');
86
87
  assert.equal(dividers.length, 1, 'one divider between the replayed prior cycle and the live one');
88
+ assert.equal(dividers[0].id, 'cy1~divider', 'the synthesized divider stays tied to its cycle marker');
87
89
  });
88
90
  test('cycleAwareMessages: a yielded cycle keeps terminal assistant content without an abort notice', () => {
89
91
  const m1 = userMsg('m1', null, 'yield after this turn');
@@ -99,11 +101,12 @@ test('cycleAwareMessages: a yielded cycle keeps terminal assistant content witho
99
101
  };
100
102
  const m3 = userMsg('m3', 'cy1', 'fresh cycle');
101
103
  const out = cycleAwareMessages(cycleManager([m1, m2, cy1, m3], 'm3'));
102
- const yieldedAssistant = out.find((message) => message.role === 'assistant');
104
+ assert.ok(out);
105
+ const yieldedAssistant = out.find(({ message }) => message.role === 'assistant').message;
103
106
  assert.deepEqual(yieldedAssistant.content, [toolCall], 'the yielded turn remains available to transcript renderers');
104
107
  assert.equal(yieldedAssistant.stopReason, undefined, 'yield is not presented as a failed assistant turn');
105
108
  assert.equal(yieldedAssistant.errorMessage, undefined, 'the provider abort text is omitted with the yield boundary');
106
- assert.equal(out.filter((message) => message.customType === 'crtr-cycle-divider').length, 1);
109
+ assert.equal(out.filter(({ message }) => message.customType === 'crtr-cycle-divider').length, 1);
107
110
  });
108
111
  // Observed shape: the yield's self-abort landed as the NEXT request was
109
112
  // starting, so the provider caught a bare AbortError instead of seeing its own
@@ -123,7 +126,8 @@ test('cycleAwareMessages: a yield abort recorded as a provider error is still no
123
126
  };
124
127
  const m3 = userMsg('m3', 'cy1', 'fresh cycle');
125
128
  const out = cycleAwareMessages(cycleManager([m1, m2, cy1, m3], 'm3'));
126
- const yieldedAssistant = out.find((message) => message.role === 'assistant');
129
+ assert.ok(out);
130
+ const yieldedAssistant = out.find(({ message }) => message.role === 'assistant').message;
127
131
  assert.equal(yieldedAssistant.stopReason, undefined, 'yield is not presented as a failed assistant turn');
128
132
  assert.equal(yieldedAssistant.errorMessage, undefined, 'the aborted-request text is omitted with the yield boundary');
129
133
  });
@@ -20,6 +20,7 @@ import { jobDir, contextDir } from './paths.js';
20
20
  import { isPidAlive } from './pid.js';
21
21
  import { resolveNodeVisual, faultSummary } from './status-glyph.js';
22
22
  import { readFault } from '../runtime/fault.js';
23
+ import { isLegacyRuntimeText, parseCard } from '../../shared/generated-context.js';
23
24
  // ---------------------------------------------------------------------------
24
25
  // Active-fault overlay: stale daemon-owned provider faults are hidden once the
25
26
  // broker pid is gone; manual/client faults stay visible until cleared.
@@ -191,6 +192,8 @@ export function readSessionParts(sessionFile) {
191
192
  const text = extractUserText(rec.message.content);
192
193
  if (text === '')
193
194
  continue;
195
+ if (parseCard({ role: 'user', content: text }) !== null || isLegacyRuntimeText(text))
196
+ continue;
194
197
  const capped = text.length > CONVO_MSG_CAP ? text.slice(0, CONVO_MSG_CAP) : text;
195
198
  parts.push(capped);
196
199
  total += capped.length + 1;
@@ -1,8 +1,11 @@
1
+ import type { InboxDisposition } from './inbox.js';
1
2
  export type PushKind = 'update' | 'urgent' | 'final';
2
3
  export interface PushOpts {
3
4
  kind: PushKind;
4
5
  body: string;
5
6
  from?: string;
7
+ /** Attribution snapshotted by the human-ticket settlement paths; absent means an ordinary report. */
8
+ disposition?: InboxDisposition;
6
9
  }
7
10
  export interface PushResult {
8
11
  reportPath: string;
@@ -28,6 +31,9 @@ export declare function pushUrgent(nodeId: string, body: string, opts?: {
28
31
  }): Promise<PushResult>;
29
32
  export declare function pushFinal(nodeId: string, body: string, opts?: {
30
33
  from?: string;
34
+ disposition?: InboxDisposition;
31
35
  }): Promise<PushResult>;
32
36
  /** Re-fan a previously committed final to its ticket origin without finalizing again. */
33
- export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string): PushResult;
37
+ export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string, opts?: {
38
+ disposition?: InboxDisposition;
39
+ }): PushResult;