@north-light/crouter 0.3.204 → 0.3.206

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 (106) hide show
  1. package/dist/api/dto/broker.d.ts +2 -0
  2. package/dist/api/dto/messages.d.ts +22 -1
  3. package/dist/api/dto/nodes.d.ts +8 -5
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.js +1 -0
  6. package/dist/clients/attach/__tests__/context-message.test.js +54 -19
  7. package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/page-block.test.js +54 -0
  9. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  10. package/dist/clients/attach/render/chat-view.js +56 -27
  11. package/dist/clients/attach/render/context-message.d.ts +6 -0
  12. package/dist/clients/attach/render/context-message.js +21 -3
  13. package/dist/clients/attach/render/group-activity.d.ts +5 -3
  14. package/dist/clients/attach/render/group-activity.js +10 -6
  15. package/dist/clients/attach/render/page-block.js +11 -11
  16. package/dist/clients/attach/viewer.js +717 -703
  17. package/dist/clients/conversation/projection.js +7 -37
  18. package/dist/commands/node/create.js +1 -1
  19. package/dist/commands/profile/project.js +1 -1
  20. package/dist/commands/profile/show.js +1 -1
  21. package/dist/commands/profile.js +1 -1
  22. package/dist/core/__tests__/canvas-inbox-watcher.test.js +90 -23
  23. package/dist/core/__tests__/context-intro.test.js +7 -8
  24. package/dist/core/__tests__/human-deliver.test.js +32 -4
  25. package/dist/core/__tests__/kickoff.test.js +6 -6
  26. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
  27. package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
  28. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
  29. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
  30. package/dist/core/__tests__/serial/revive.test.js +8 -3
  31. package/dist/core/__tests__/session-cycles.test.js +10 -6
  32. package/dist/core/canvas/render-source.js +3 -0
  33. package/dist/core/feed/feed.d.ts +7 -1
  34. package/dist/core/feed/feed.js +8 -7
  35. package/dist/core/feed/inbox.d.ts +16 -12
  36. package/dist/core/feed/inbox.js +56 -56
  37. package/dist/core/human/page-eval.d.ts +9 -4
  38. package/dist/core/human/page-eval.js +3 -1
  39. package/dist/core/human/page-markdown.d.ts +3 -0
  40. package/dist/core/human/page-markdown.js +295 -0
  41. package/dist/core/human/page.js +4 -0
  42. package/dist/core/runtime/bearings.js +5 -4
  43. package/dist/core/runtime/broker/client-registry.d.ts +1 -0
  44. package/dist/core/runtime/broker/client-registry.js +1 -0
  45. package/dist/core/runtime/broker/fault-retry.js +8 -3
  46. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  47. package/dist/core/runtime/broker/frame-dispatch.js +13 -2
  48. package/dist/core/runtime/broker/inbox.d.ts +20 -2
  49. package/dist/core/runtime/broker/inbox.js +68 -25
  50. package/dist/core/runtime/broker/passive.d.ts +1 -0
  51. package/dist/core/runtime/broker/tool-groups.js +3 -2
  52. package/dist/core/runtime/broker-extension-render.d.ts +3 -0
  53. package/dist/core/runtime/broker-extension-render.js +3 -0
  54. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  55. package/dist/core/runtime/broker-protocol.js +11 -0
  56. package/dist/core/runtime/broker.d.ts +2 -2
  57. package/dist/core/runtime/broker.js +31 -6
  58. package/dist/core/runtime/deliver-live.js +2 -1
  59. package/dist/core/runtime/interactive-deliver.d.ts +3 -2
  60. package/dist/core/runtime/interactive-deliver.js +4 -3
  61. package/dist/core/runtime/kickoff.d.ts +3 -4
  62. package/dist/core/runtime/kickoff.js +27 -16
  63. package/dist/core/runtime/node-read.d.ts +2 -0
  64. package/dist/core/runtime/node-read.js +11 -3
  65. package/dist/core/runtime/session-cycles.d.ts +6 -1
  66. package/dist/core/runtime/session-cycles.js +17 -11
  67. package/dist/core/runtime/session-visibility.d.ts +7 -12
  68. package/dist/core/runtime/session-visibility.js +7 -13
  69. package/dist/core/runtime/situational-context.d.ts +14 -5
  70. package/dist/core/runtime/situational-context.js +39 -21
  71. package/dist/core/runtime/situational-live.d.ts +2 -2
  72. package/dist/core/runtime/situational-live.js +17 -16
  73. package/dist/core/runtime/spawn.js +4 -3
  74. package/dist/core/runtime/stop-guard.js +8 -4
  75. package/dist/core/runtime/warm-pool.d.ts +1 -1
  76. package/dist/core/runtime/warm-pool.js +6 -9
  77. package/dist/daemon/api/handlers/messages.js +101 -17
  78. package/dist/daemon/api/handlers/nodes.js +12 -8
  79. package/dist/daemon/human/finish.js +44 -4
  80. package/dist/daemon/messaging/node-message.d.ts +5 -0
  81. package/dist/daemon/messaging/node-message.js +6 -1
  82. package/dist/daemon/review/comment-notify.js +8 -0
  83. package/dist/daemon/review/deliver.js +2 -2
  84. package/dist/daemon/review/finish.js +1 -0
  85. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
  86. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
  87. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  88. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
  89. package/dist/pi-extensions/broker-local.d.ts +0 -2
  90. package/dist/pi-extensions/broker-local.js +0 -2
  91. package/dist/pi-extensions/canvas-context-intro.js +9 -8
  92. package/dist/pi-extensions/canvas-goal-capture.js +10 -6
  93. package/dist/pi-extensions/canvas-inbox-watcher.js +116 -69
  94. package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
  95. package/dist/pi-extensions/canvas-passive-context.js +23 -3
  96. package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
  97. package/dist/pi-extensions/canvas-review-boundary.js +11 -3
  98. package/dist/pi-extensions/canvas-stophook.js +3 -3
  99. package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
  100. package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
  101. package/dist/shared/generated-context.d.ts +68 -36
  102. package/dist/shared/generated-context.js +300 -174
  103. package/dist/shared/tool-groups.js +3 -2
  104. package/package.json +1 -1
  105. package/runtime.lock.json +2 -2
  106. package/scripts/postinstall.mjs +9 -0
@@ -96,6 +96,8 @@ function scanPage(document, code, productComponents) {
96
96
  const pages = [];
97
97
  const collectPages = (nodes) => {
98
98
  for (const node of nodes) {
99
+ if (node.kind === 'text')
100
+ continue;
99
101
  if (node.tag === 'Page')
100
102
  pages.push(node);
101
103
  collectPages(node.children);
@@ -126,6 +128,8 @@ function scanPage(document, code, productComponents) {
126
128
  let stepCount = 0;
127
129
  const collectSlots = (nodes, step) => {
128
130
  for (const node of nodes) {
131
+ if (node.kind === 'text')
132
+ continue;
129
133
  const inStep = node.tag === 'Step' ? stepCount++ : step;
130
134
  const builtin = BUILTIN_KIND_BY_TAG[node.tag];
131
135
  const product = productTags.get(node.tag);
@@ -18,7 +18,7 @@ import { hostname } from 'node:os';
18
18
  import { cadenceDisplay } from '../wake.js';
19
19
  import { renderKnowledgeBlock, renderWorkspaceOpenDocs } from '../substrate/index.js';
20
20
  import { loadProfileManifest } from '../profiles/manifest.js';
21
- import { situationalContextBlock } from './situational-context.js';
21
+ import { situationalContextEnvelope } from './situational-context.js';
22
22
  import { statusRank } from '../canvas/node-order.js';
23
23
  /** The bearings custom type is shared with every attached renderer. */
24
24
  export { CONTEXT_INTRO_CUSTOM_TYPE } from '../../shared/generated-context.js';
@@ -369,9 +369,10 @@ export function buildContextBearings(nodeId, seen) {
369
369
  // A workspace mount is the initial read of its root (`applies-to: "."`).
370
370
  const workspaceContext = renderWorkspaceOpenDocs(nodeId, seen);
371
371
  // Hidden applet/situation-origin ambient context (see situational-context.ts)
372
- // — a SIBLING block, never nested under <crtr-bearings>, and never routed
373
- // through the visible chat/digest path. '' when nothing was ever set.
374
- const situational = situationalContextBlock(nodeId);
372
+ // — the stored `situational` card, placed in the bearings BODY, so it is a
373
+ // nested card inside the outer envelope. Never routed through the visible
374
+ // chat/digest path. '' when nothing was ever set.
375
+ const situational = situationalContextEnvelope(nodeId) ?? '';
375
376
  const projectCtx = node?.cwd !== undefined ? buildProjectContextBlock(node.cwd) : '';
376
377
  return [bearings.join('\n'), knowledge, workspaceContext, situational, projectCtx]
377
378
  .filter((s) => s !== '')
@@ -10,6 +10,7 @@ export interface BrokerClient {
10
10
  socket: Socket;
11
11
  decoder: FrameDecoder;
12
12
  helloed: boolean;
13
+ snapshotTail: number | undefined;
13
14
  pendingBytes: number;
14
15
  queuedFrames: number;
15
16
  }
@@ -36,6 +36,7 @@ export class BrokerClientRegistry {
36
36
  socket,
37
37
  decoder: new FrameDecoder(BROKER_READ_CAPS),
38
38
  helloed: false,
39
+ snapshotTail: undefined,
39
40
  pendingBytes: 0,
40
41
  queuedFrames: 0,
41
42
  };
@@ -5,7 +5,7 @@ import { extractCoolingDeadline } from '../managed-provider-cooling.js';
5
5
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../model-routes.js';
6
6
  import { operationIdContext } from '../../events/operation-id.js';
7
7
  import { emitEvent } from '../../events/emit.js';
8
- import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatModelFallbackRecovery } from '../../../shared/generated-context.js';
8
+ import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatCard, formatModelFallbackRecovery } from '../../../shared/generated-context.js';
9
9
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery.js';
10
10
  import { parseModelSpec } from '../model-selection.js';
11
11
  import { resolveProviderCandidates } from '../../subscription-state.js';
@@ -208,7 +208,12 @@ export class FaultRetry {
208
208
  return;
209
209
  if (unconfigured)
210
210
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
211
- await operationIdContext.fresh(() => candidateSession.prompt(formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found')));
211
+ await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {
212
+ reason: 'model-fallback',
213
+ from: currentSpec,
214
+ to: resolved,
215
+ cause: unconfigured ? 'credential' : 'not-found',
216
+ }, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found'))));
212
217
  })().catch((err) => {
213
218
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` } });
214
219
  }).finally(() => {
@@ -295,6 +300,6 @@ export class FaultRetry {
295
300
  if (this.deps.installedGeneration() !== generation || this.deps.currentSession() !== candidateSession)
296
301
  return;
297
302
  recordFault(this.deps.nodeId, plan.fault);
298
- await operationIdContext.fresh(() => candidateSession.prompt(fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY));
303
+ await operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', { reason: fault.kind === 'connection' ? 'connection' : 'provider' }, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY)));
299
304
  }
300
305
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentSessionServices, ModelRegistry, PromptOptions } from '@earendil-works/pi-coding-agent';
2
2
  import type { ImageContent } from '@earendil-works/pi-ai';
3
3
  import { type BrokerSdkConfig } from '../launch.js';
4
- import type { ClientToBroker } from '../broker-protocol.js';
4
+ import { type ClientToBroker } from '../broker-protocol.js';
5
5
  import type { BrokerClient, BrokerClientRegistry } from './client-registry.js';
6
6
  import { type BrokerSession } from './read-ops.js';
7
7
  import type { ToolGroupTracker } from './tool-groups.js';
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { AUTH_FAULT_RECOVERY_BODY } from '../../../shared/generated-context.js';
6
+ import { AUTH_FAULT_RECOVERY_BODY, formatCard, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from '../../../shared/generated-context.js';
7
7
  import { emitEvent } from '../../events/emit.js';
8
8
  import { operationIdContext } from '../../events/operation-id.js';
9
9
  import { buildRefInventory } from '../../memory/inline-ref-inventory.js';
@@ -16,6 +16,7 @@ import { SessionListCache } from '../session-list-cache.js';
16
16
  import { sessionListCachePath } from '../../canvas/paths.js';
17
17
  import { isReviewBoundary } from '../session-visibility.js';
18
18
  import { isUnknownModel, parseModelSpec } from '../model-selection.js';
19
+ import { normalizeSnapshotTail } from '../broker-protocol.js';
19
20
  import { buildCommandList, buildGetTreeData, buildListMemoryRefsData, buildListModelsData, buildScopedModelsData, buildSessionsData, buildSettingsData, modelKey, } from './read-ops.js';
20
21
  export const REVIEW_BOUNDARY_NAVIGATION_CODE = 'review_boundary_navigation';
21
22
  export class ReviewBoundaryNavigationError extends Error {
@@ -752,6 +753,7 @@ export function createFrameDispatchContext(deps) {
752
753
  // stated. A duplicate hello still re-sends the catch-up snapshot.
753
754
  const firstHello = !client.helloed;
754
755
  client.id = frame.client_id;
756
+ client.snapshotTail = normalizeSnapshotTail(frame.snapshot_tail);
755
757
  if (firstHello) {
756
758
  client.helloed = true;
757
759
  client.role = frame.role === 'controller' ? 'controller' : 'observer';
@@ -792,6 +794,15 @@ export function createFrameDispatchContext(deps) {
792
794
  if (notWritable(client, 'drive the engine', frame.id))
793
795
  return;
794
796
  const via = currentSession().isStreaming ? 'steer' : 'prompt';
797
+ // Ordered context cards ahead of the body, inside this ONE frame handling,
798
+ // so no other client's send can interleave between a card and its body.
799
+ // Nothing is awaited: `sendCustomMessage` completes synchronously on both
800
+ // branches that apply here — idle appends to the session and starts NO turn
801
+ // (the body is the sole trigger), streaming queues on the same steer queue
802
+ // the body's `prompt({streamingBehavior:'steer'})` uses, preserving order.
803
+ for (const card of frame.cards ?? []) {
804
+ void currentSession().sendCustomMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, via === 'steer' ? { deliverAs: 'steer' } : undefined).catch((error) => emitEvent({ level: 'error', event: 'broker.deliver.card_failed', error }));
805
+ }
795
806
  let delivered = false;
796
807
  const ackAccepted = () => {
797
808
  if (delivered)
@@ -1431,7 +1442,7 @@ export function createFrameDispatchContext(deps) {
1431
1442
  fresh.since !== authFault.since)
1432
1443
  return;
1433
1444
  clearFault(nodeId, { link: 'pi→provider' });
1434
- await authFaultSession.prompt(AUTH_FAULT_RECOVERY_BODY);
1445
+ await authFaultSession.prompt(formatCard('recovery', { reason: 'auth' }, AUTH_FAULT_RECOVERY_BODY));
1435
1446
  }
1436
1447
  catch (err) {
1437
1448
  emitEvent({
@@ -8,11 +8,14 @@ export interface BrokerInboxEntry {
8
8
  operation_id: OperationId;
9
9
  ts: string;
10
10
  from: string | null;
11
+ from_name?: string;
11
12
  tier: 'critical' | 'urgent' | 'normal' | 'deferred';
12
13
  kind: 'update' | 'urgent' | 'final' | 'message' | 'completed';
13
14
  ref?: string;
14
15
  label: string;
15
16
  data?: Record<string, unknown>;
17
+ /** Set only by the human-ticket settlement paths; absent means an ordinary report. */
18
+ disposition?: 'human-answer' | 'human-canceled';
16
19
  }
17
20
  export declare function readBrokerCursor(nodeId: string): BrokerInboxEntryId | undefined;
18
21
  export declare function readBrokerInboxSince(nodeId: string, cursor?: string): BrokerInboxEntry[];
@@ -22,5 +25,20 @@ export declare function readBrokerCanceledEntryIds(nodeId: string): Set<BrokerIn
22
25
  * absent, absolute, or not a report path. Callers use it to tell whether the
23
26
  * report-node projection they hold can still resolve the ref. */
24
27
  export declare function reportRefNodeId(ref: string | undefined): string | undefined;
25
- /** Render the existing inbox digest wording with daemon-projected report existence. */
26
- export declare function coalesceBrokerInbox(entries: readonly BrokerInboxEntry[], reportNodes: ReadonlyMap<string, BrokerReportNodeDTO>): string;
28
+ /** One-shot runtime cards an entry carries (rendered envelopes written by the
29
+ * message API). They ride the delivery their own entry belongs to, so a note
30
+ * can never land ahead of an unrelated person's message. */
31
+ export declare function entryCards(entry: BrokerInboxEntry): readonly string[];
32
+ /** One deliverable user message: either a person's verbatim words or the whole
33
+ * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
34
+ * only as a whole message, so mixing them attributes the card to the person.
35
+ * `cards` are the one-shot context envelopes that go out ahead of `text`. */
36
+ export interface InboxDelivery {
37
+ kind: 'human' | 'card';
38
+ text: string;
39
+ cards: readonly string[];
40
+ entries: readonly BrokerInboxEntry[];
41
+ }
42
+ /** Split unread entries into ordered deliveries: each human entry verbatim, and
43
+ * every node/system entry in one card landing where the first such sender fell. */
44
+ export declare function coalesceBrokerInbox(entries: readonly BrokerInboxEntry[], reportNodes: ReadonlyMap<string, BrokerReportNodeDTO>): readonly InboxDelivery[];
@@ -2,6 +2,7 @@
2
2
  // broker only reads its own files and uses daemon-projected report senders.
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { inboxPath, nodeDir } from '../../canvas/paths.js';
5
+ import { formatInboxCard } from '../../../shared/generated-context.js';
5
6
  const ENTRY_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
6
7
  const BODY_MAX_LINES = 12;
7
8
  const BODY_MAX_CHARS = 1000;
@@ -115,40 +116,61 @@ export function reportRefNodeId(ref) {
115
116
  return undefined;
116
117
  return ref.slice(0, separator);
117
118
  }
119
+ /** Reports are written with a YAML frontmatter block; only the body belongs in
120
+ * the card, so a human answer's entry carries the person's words alone. */
121
+ function stripFrontmatter(raw) {
122
+ if (!raw.startsWith('---\n'))
123
+ return raw;
124
+ const end = raw.indexOf('\n---\n', 4);
125
+ return end === -1 ? raw : raw.slice(end + 5);
126
+ }
118
127
  function inlineReport(entry, reportNodes) {
119
128
  const nodeId = reportRefNodeId(entry.ref);
120
129
  if (nodeId === undefined || !reportNodes.has(nodeId))
121
130
  return null;
122
131
  const relpath = entry.ref.slice(entry.ref.indexOf(':') + 1);
123
- const path = `${nodeDir(nodeId)}/${relpath}`;
124
132
  try {
125
- const body = readFileSync(path, 'utf8');
126
- return body.length >= INLINE_REPORT_MAX_CHARS ? null : { body, path };
133
+ const body = stripFrontmatter(readFileSync(`${nodeDir(nodeId)}/${relpath}`, 'utf8'));
134
+ return body.length >= INLINE_REPORT_MAX_CHARS ? null : body;
127
135
  }
128
136
  catch {
129
137
  return null;
130
138
  }
131
139
  }
132
- function renderEntry(entry, reportNodes) {
140
+ /** One entry's card body: an inlined short report, a bounded inline message
141
+ * body, or the label when the entry carries neither. */
142
+ function entryBody(entry, reportNodes) {
133
143
  const report = inlineReport(entry, reportNodes);
134
- if (report !== null) {
135
- const indented = report.body.trimEnd().split('\n').map((line) => ` ${line}`).join('\n');
136
- return { text: ` [${entry.kind}]\n${indented}\n (report: ${report.path})`, hasCanonicalRef: false };
137
- }
144
+ if (report !== null)
145
+ return report.trimEnd();
138
146
  const body = inlineBody(entry);
139
- if (body === '') {
140
- const hasCanonicalRef = entry.ref !== undefined && !entry.ref.startsWith('/');
141
- return { text: entry.ref === undefined ? ` [${entry.kind}] ${entry.label}` : ` [${entry.kind}] ${entry.label} (ref: ${entry.ref})`, hasCanonicalRef };
142
- }
147
+ if (body === '')
148
+ return entry.label;
143
149
  const { text, clipped } = clipBody(body);
144
- const indented = text.split('\n').map((line) => ` ${line}`).join('\n');
145
- const more = entry.ref !== undefined ? `\n … (full body: ${entry.ref})` : clipped ? '\n … (body clipped)' : '';
146
- return { text: ` [${entry.kind}]\n${indented}${more}`, hasCanonicalRef: false };
150
+ return clipped && entry.ref === undefined ? `${text}\n… (body clipped)` : text;
147
151
  }
148
- /** Render the existing inbox digest wording with daemon-projected report existence. */
152
+ function cardEntry(entry, reportNodes) {
153
+ return {
154
+ kind: entry.kind,
155
+ body: entryBody(entry, reportNodes),
156
+ ...(entry.ref === undefined ? {} : { ref: entry.ref }),
157
+ ...(entry.disposition === undefined ? {} : { disposition: entry.disposition }),
158
+ };
159
+ }
160
+ /** One-shot runtime cards an entry carries (rendered envelopes written by the
161
+ * message API). They ride the delivery their own entry belongs to, so a note
162
+ * can never land ahead of an unrelated person's message. */
163
+ export function entryCards(entry) {
164
+ const cards = entry.data?.['cards'];
165
+ if (!Array.isArray(cards))
166
+ return [];
167
+ return cards.filter((card) => typeof card === 'string');
168
+ }
169
+ /** Split unread entries into ordered deliveries: each human entry verbatim, and
170
+ * every node/system entry in one card landing where the first such sender fell. */
149
171
  export function coalesceBrokerInbox(entries, reportNodes) {
150
172
  if (entries.length === 0)
151
- return '(inbox empty)';
173
+ return [];
152
174
  const groups = new Map();
153
175
  for (const entry of entries) {
154
176
  const key = entry.from ?? 'system';
@@ -156,21 +178,42 @@ export function coalesceBrokerInbox(entries, reportNodes) {
156
178
  groups.set(key, []);
157
179
  groups.get(key).push(entry);
158
180
  }
159
- let hasCanonicalRef = false;
181
+ const deliveries = [];
160
182
  const sections = [];
183
+ let cardSlot = -1;
161
184
  for (const [sender, items] of groups) {
162
185
  if (sender === 'human') {
163
186
  for (const entry of items) {
164
187
  const body = inlineBody(entry);
165
- sections.push(body === '' ? entry.label : body);
188
+ deliveries.push({
189
+ kind: 'human',
190
+ text: body === '' ? entry.label : body,
191
+ cards: entryCards(entry),
192
+ entries: [entry],
193
+ });
166
194
  }
167
195
  continue;
168
196
  }
169
- const lines = items.map((entry) => renderEntry(entry, reportNodes));
170
- if (lines.some((line) => line.hasCanonicalRef))
171
- hasCanonicalRef = true;
172
- sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.map((line) => line.text).join('\n')}`);
197
+ if (cardSlot < 0) {
198
+ cardSlot = deliveries.length;
199
+ deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
200
+ }
201
+ // The fallback to a CURRENT projected name is correct only for legacy
202
+ // entries, which predate the producer's own `from_name` snapshot.
203
+ const name = items.map((item) => item.from_name).filter((value) => value !== undefined && value !== '').at(-1)
204
+ ?? reportNodes.get(sender)?.name;
205
+ sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map((entry) => cardEntry(entry, reportNodes)) });
206
+ }
207
+ if (cardSlot >= 0) {
208
+ // Card sections group by sender, so its entry list comes from the input
209
+ // array instead: a delivery's entry ids must be in physical inbox order.
210
+ const cardEntries = entries.filter((entry) => (entry.from ?? 'system') !== 'human');
211
+ deliveries[cardSlot] = {
212
+ kind: 'card',
213
+ text: formatInboxCard(sections),
214
+ cards: cardEntries.flatMap((entry) => entryCards(entry)),
215
+ entries: cardEntries,
216
+ };
173
217
  }
174
- const digest = sections.join('\n\n');
175
- return hasCanonicalRef ? `${digest}\n\nDereference a ref with \`crtr canvas history read <ref>\`.` : digest;
218
+ return deliveries;
176
219
  }
@@ -5,6 +5,7 @@ export interface BrokerPassiveEntry {
5
5
  operation_id: string;
6
6
  ts: string;
7
7
  from: string | null;
8
+ from_name?: string;
8
9
  tier: string;
9
10
  kind: string;
10
11
  ref?: string;
@@ -5,7 +5,7 @@ import { readConfig } from '../../config.js';
5
5
  import { emitEvent } from '../../events/emit.js';
6
6
  import { atomicWriteJson, readJsonIfExists } from '../../fs-utils.js';
7
7
  import { assistantVisibleText, isTrueUserMessage, normalizeToolGroupSummaryRecord, } from '../../../shared/tool-groups.js';
8
- import { generatedContextText } from '../../../shared/generated-context.js';
8
+ import { generatedContextText, parseCard } from '../../../shared/generated-context.js';
9
9
  import { headlessToolGroupSummary, serializeToolGroup } from '../tool-group-summary.js';
10
10
  const PENDING_TOOL_SUMMARY_KEY_MAX_CHARS = 1_000;
11
11
  const PENDING_TOOL_SUMMARY_SEGMENT_MAX_CHARS = 10_000;
@@ -140,7 +140,8 @@ export class ToolGroupTracker {
140
140
  if (item.display === false)
141
141
  return;
142
142
  const text = generatedContextText(item);
143
- const isBearingsOrRevive = item.customType === 'crtr-context' || (item.role === 'user' && text.startsWith('You have been revived fresh after a context refresh'));
143
+ const kind = parseCard(item)?.kind;
144
+ const isBearingsOrRevive = kind === 'bearings' || kind === 'revive';
144
145
  if (isBearingsOrRevive) {
145
146
  if (this.openToolGroup !== undefined && text !== '')
146
147
  this.openToolGroup.pieces.push({ type: 'text', text });
@@ -3,6 +3,9 @@ export declare function editorLabelForBrokerNode(node: BrokerExtensionNodeDTO):
3
3
  /** Same environment-only project context rendered at normal boot, without a
4
4
  * canvas import. */
5
5
  export declare function buildProjectContextBlockForBroker(cwd: string): string;
6
+ /** `situational` is the node's stored situational card, placed in the bearings
7
+ * BODY — the one place in the system where a runtime card nests inside
8
+ * another (see situational-context.ts). '' when none is set. */
6
9
  export declare function buildContextBearingsFromState(state: BrokerExtensionStateDTO, projectContext: string, situational?: string, seen?: Set<string>): string;
7
10
  export declare function buildForkBearingsFromState(state: BrokerExtensionStateDTO, situational?: string): string;
8
11
  export declare function renderPreferencesFromState(state: BrokerExtensionStateDTO): string;
@@ -139,6 +139,9 @@ function reviewNote(node) {
139
139
  }
140
140
  return `You exist for one human review of \`${review.target_file}\`. The inherited conversation belongs to the origin node, not you — carry only the context needed for this review. The person's visible transcript begins at the boundary marker above; everything earlier is context they cannot see.`;
141
141
  }
142
+ /** `situational` is the node's stored situational card, placed in the bearings
143
+ * BODY — the one place in the system where a runtime card nests inside
144
+ * another (see situational-context.ts). '' when none is set. */
142
145
  export function buildContextBearingsFromState(state, projectContext, situational = '', seen) {
143
146
  const { node } = state;
144
147
  const bearings = ['<crtr-bearings>', identity(state)];
@@ -18,6 +18,9 @@ export type ClientRole = 'controller' | 'observer';
18
18
  * import. */
19
19
  export interface BrokerSnapshot {
20
20
  messages: AgentSession['messages'];
21
+ /** Stable session-entry ids aligned 1:1 with `messages`. Absent when the
22
+ * session manager cannot expose its entry tree. */
23
+ messageIds?: string[];
21
24
  stats: SessionStats;
22
25
  /** pi's OWN `get_state` payload, verbatim — not a mirror. `buildSnapshot`
23
26
  * fills it exactly as pi's `rpc-mode.js` does, from the same getters, so a
@@ -69,12 +72,18 @@ export interface BrokerSnapshot {
69
72
  * `Working...` when it is absent. */
70
73
  workingActivity?: string;
71
74
  }
75
+ /** Largest transcript window a client may request in its welcome snapshot. */
76
+ export declare const SNAPSHOT_TAIL_MAX = 500;
77
+ /** Invalid requests preserve the complete welcome; valid requests are capped. */
78
+ export declare function normalizeSnapshotTail(value: unknown): number | undefined;
72
79
  /** Opens the connection and FIXES this client's role for its lifetime. A repeated
73
80
  * `hello` on an already-helloed socket never changes the established role. */
74
81
  export interface HelloFrame {
75
82
  type: 'hello';
76
83
  role: ClientRole;
77
84
  client_id: string;
85
+ /** Per-attach welcome transcript bound; absent or invalid keeps the full history. */
86
+ snapshot_tail?: number;
78
87
  /** Terminal geometry of a tmux-pane viewer; absent for a headless client. */
79
88
  term?: {
80
89
  cols: number;
@@ -125,6 +134,11 @@ export interface DeliverFrame {
125
134
  id: string;
126
135
  text: string;
127
136
  images?: ImageContent[];
137
+ /** Rendered runtime-card envelopes delivered ahead of `text`, in order, in
138
+ * this ONE frame handling — so no other client's send can interleave
139
+ * between a card and the body it belongs to. Each is its own custom
140
+ * message and none triggers a turn; the body is the sole trigger. */
141
+ cards?: string[];
128
142
  }
129
143
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
130
144
  * which runs the command, records a `bashExecution` message in context, and
@@ -17,6 +17,17 @@
17
17
  // follow pi.
18
18
  import { StringDecoder } from 'node:string_decoder';
19
19
  // ---------------------------------------------------------------------------
20
+ // Client → broker frames
21
+ // ---------------------------------------------------------------------------
22
+ /** Largest transcript window a client may request in its welcome snapshot. */
23
+ export const SNAPSHOT_TAIL_MAX = 500;
24
+ /** Invalid requests preserve the complete welcome; valid requests are capped. */
25
+ export function normalizeSnapshotTail(value) {
26
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
27
+ return undefined;
28
+ return Math.min(value, SNAPSHOT_TAIL_MAX);
29
+ }
30
+ // ---------------------------------------------------------------------------
20
31
  // Newline-delimited JSON codec (the framing pi solves with `jsonl`, reproduced
21
32
  // locally so the protocol module owns no transport dependency)
22
33
  // ---------------------------------------------------------------------------
@@ -3,7 +3,7 @@ import { type BrokerSdkConfig } from './launch.js';
3
3
  import { type BrokerEngine } from './broker-sdk.js';
4
4
  import { type KnownStreamWaitControl } from './stream-watchdog.js';
5
5
  import { type BrokerClient } from './broker/client-registry.js';
6
- import { type BrokerSnapshot, type BrokerToClient, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from './broker-protocol.js';
6
+ import { type BrokerToClient, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from './broker-protocol.js';
7
7
  /** A review companion may only navigate or fork within a branch whose ancestry
8
8
  * has crossed its durable transcript boundary. The broker sends this typed local
9
9
  * failure as a dedicated error frame without invoking pi's tree mutation. */
@@ -29,7 +29,7 @@ interface PendingDialog {
29
29
  * bash children. No-op before the session is built or after a clean dispose. */
30
30
  export declare function disposeActiveSession(): void;
31
31
  export declare function runBroker(nodeId: string, startupAt?: bigint, startupTs?: string): Promise<void>;
32
- export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): BrokerSnapshot['messages'];
32
+ export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-cycles.js").IdentifiedMessage[] | undefined;
33
33
  export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkConfig): Promise<{
34
34
  session: CreateAgentSessionResult['session'];
35
35
  services: AgentSessionServices;
@@ -48,7 +48,7 @@ import { EventProjection } from './broker/event-projection.js';
48
48
  import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
49
49
  import { RebindDriver } from './broker/rebind.js';
50
50
  import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
51
- import { FrameOverflowError, } from './broker-protocol.js';
51
+ import { FrameOverflowError, normalizeSnapshotTail, } from './broker-protocol.js';
52
52
  /** A review companion may only navigate or fork within a branch whose ancestry
53
53
  * has crossed its durable transcript boundary. The broker sends this typed local
54
54
  * failure as a dedicated error frame without invoking pi's tree mutation. */
@@ -355,10 +355,23 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
355
355
  disposeAndExit('daemon-state-read-failed', 1);
356
356
  },
357
357
  });
358
- const buildSnapshot = () => {
358
+ const buildSnapshot = (client) => {
359
359
  const liveSession = rebind.session();
360
+ const identified = snapshotMessages(liveSession, boundaryReviewId);
361
+ const tail = normalizeSnapshotTail(client.snapshotTail);
362
+ const retained = identified === undefined || tail === undefined ? identified : identified.slice(-tail);
363
+ const messages = retained?.map(({ message }) => message)
364
+ ?? (tail === undefined
365
+ ? liveSession.sessionManager.buildSessionContext().messages
366
+ : liveSession.sessionManager.buildSessionContext().messages.slice(-tail));
367
+ const fullToolGroupSummaries = toolGroups.summaries();
368
+ const retainedToolCallIds = tail === undefined ? undefined : toolCallIds(messages);
369
+ const toolGroupSummaries = retainedToolCallIds === undefined
370
+ ? fullToolGroupSummaries
371
+ : Object.fromEntries(Object.entries(fullToolGroupSummaries).filter(([id]) => retainedToolCallIds.has(id)));
360
372
  return ({
361
- messages: snapshotMessages(liveSession, boundaryReviewId),
373
+ messages,
374
+ ...(retained === undefined ? {} : { messageIds: retained.map(({ id }) => id) }),
362
375
  stats: liveSession.getSessionStats(),
363
376
  // pi's `get_state` (rpc-mode.js), field for field, from the same getters.
364
377
  // The ONE divergence: a model-less session (undefined OR the SDK
@@ -383,7 +396,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
383
396
  display: {
384
397
  ...registry.displaySnapshot(),
385
398
  },
386
- toolGroupSummaries: toolGroups.summaries(),
399
+ toolGroupSummaries,
387
400
  workingActivity: projection.currentWorkingActivity(),
388
401
  // The UNRUN queue's texts, read non-destructively (`clearQueue()` would
389
402
  // consume them). Same two arrays a live `queue_update` carries, so a client
@@ -404,7 +417,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
404
417
  const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
405
418
  registry.sendFrame(client, {
406
419
  type: 'welcome',
407
- snapshot: buildSnapshot(),
420
+ snapshot: buildSnapshot(client),
408
421
  role: client.role,
409
422
  pending_dialog: first !== undefined ? first.request : null,
410
423
  agentDir: getAgentDir(),
@@ -684,13 +697,25 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
684
697
  // conversation instead of just the post-yield window; single-cycle sessions and
685
698
  // the fake-engine fixture (no tree accessors) fall through to plain
686
699
  // `buildSessionContext().messages` unchanged.
700
+ function toolCallIds(messages) {
701
+ const ids = new Set();
702
+ for (const message of messages) {
703
+ if (message.role !== 'assistant' || !Array.isArray(message.content))
704
+ continue;
705
+ for (const part of message.content) {
706
+ if (part !== null && typeof part === 'object' && part.type === 'toolCall' && typeof part.id === 'string')
707
+ ids.add(part.id);
708
+ }
709
+ }
710
+ return ids;
711
+ }
687
712
  export function snapshotMessages(session, boundaryReviewId) {
688
713
  // Cast: the real SDK's `SessionEntry.data` is `unknown` (untyped custom-entry
689
714
  // payload), narrower than `CycleSessionManagerLike`'s structural read of the
690
715
  // `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
691
716
  // are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
692
717
  const messages = cycleAwareMessages(session.sessionManager);
693
- return visibleMessages(messages, { boundaryReviewId });
718
+ return messages === undefined ? undefined : visibleMessages(messages, { boundaryReviewId });
694
719
  }
695
720
  // ---------------------------------------------------------------------------
696
721
  // resolveLaunchModel — the SOLE launch model-selection policy, module-scoped
@@ -22,6 +22,7 @@ import { oneShotControllerRequest } from './broker-request.js';
22
22
  import { isBrokerLive } from './model-swap.js';
23
23
  import { buildContextBearings, CONTEXT_INTRO_CUSTOM_TYPE } from './bearings.js';
24
24
  import { loadInjectedDocs, saveInjectedDocs } from '../substrate/injected-store.js';
25
+ import { formatCard } from '../../shared/generated-context.js';
25
26
  /** Deliver `message` into `nodeId`'s live session. Resolves without doing
26
27
  * anything when the node has no live broker. Rejects when a live broker cannot
27
28
  * be reached or refuses the frame — the caller decides what that means (for a
@@ -66,7 +67,7 @@ export async function deliverBearingsLive(nodeId) {
66
67
  const seen = loadInjectedDocs(nodeId);
67
68
  await deliverCustomMessageLive(nodeId, {
68
69
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
69
- content: buildContextBearings(nodeId, seen),
70
+ content: formatCard('bearings', {}, buildContextBearings(nodeId, seen)),
70
71
  details: { nodeId },
71
72
  });
72
73
  saveInjectedDocs(nodeId, seen);
@@ -6,8 +6,9 @@ export type LiveDeliverRoute = 'prompt' | 'steer';
6
6
  export type LiveInterruptOutcome = 'turn' | 'bash' | 'idle';
7
7
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
8
8
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
9
- * routed; resolves with the route taken. */
10
- export declare function deliverLive(nodeId: string, text: string): Promise<LiveDeliverRoute>;
9
+ * routed; resolves with the route taken. `cards` are rendered runtime-card
10
+ * envelopes placed ahead of the body inside that same frame handling. */
11
+ export declare function deliverLive(nodeId: string, text: string, cards?: readonly string[]): Promise<LiveDeliverRoute>;
11
12
  /** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
12
13
  * once the broker has PROCESSED the abort — which, on the single frame loop,
13
14
  * also implies every earlier-accepted deliver was already routed (so a
@@ -14,14 +14,15 @@
14
14
  import { oneShotControllerRequest } from './broker-request.js';
15
15
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
16
16
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
17
- * routed; resolves with the route taken. */
18
- export function deliverLive(nodeId, text) {
17
+ * routed; resolves with the route taken. `cards` are rendered runtime-card
18
+ * envelopes placed ahead of the body inside that same frame handling. */
19
+ export function deliverLive(nodeId, text, cards = []) {
19
20
  return oneShotControllerRequest({
20
21
  nodeId,
21
22
  what: 'the interactive delivery',
22
23
  ackFor: 'deliver',
23
24
  correlated: true,
24
- frame: (id) => ({ type: 'deliver', id, text }),
25
+ frame: (id) => ({ type: 'deliver', id, text, ...(cards.length > 0 ? { cards: [...cards] } : {}) }),
25
26
  result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
26
27
  });
27
28
  }
@@ -1,6 +1,8 @@
1
1
  import { type NodeMeta } from '../canvas/index.js';
2
2
  import { type WakeOrigin } from './bearings.js';
3
- export { REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, } from '../../shared/generated-context.js';
3
+ /** The runtime-restart continuation exactly as delivered. `revive.ts` injects
4
+ * it as the prompt of a strict resume that follows a cleanly aborted turn. */
5
+ export declare const RUNTIME_RESTART_CONTINUATION: string;
4
6
  /** The goal file — the prompt/task a node was spawned with, persisted at birth
5
7
  * so a fresh revive can re-read its mandate. */
6
8
  export declare function goalPath(nodeId: string): string;
@@ -13,9 +15,6 @@ export declare function writeGoal(nodeId: string, text: string): void;
13
15
  * goal. Returns true when it wrote one, false when a goal already existed or
14
16
  * the text was empty. Guarded so a later message never clobbers the mandate. */
15
17
  export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
16
- /** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
17
- * The goal-capture extension and attached renderers share its pure definition,
18
- * so the same exact contract controls both prompt exclusion and display. */
19
18
  /** The yield-message file — a short note `crtr node yield` records for the next
20
19
  * fresh/cycling revive ("on wake, do X"). It remains durable through failed
21
20
  * launch attempts and is cleared only when session_start confirms a boot. */