@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
@@ -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 } 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';
@@ -1431,7 +1433,7 @@ export function createFrameDispatchContext(deps) {
1431
1433
  fresh.since !== authFault.since)
1432
1434
  return;
1433
1435
  clearFault(nodeId, { link: 'pi→provider' });
1434
- await authFaultSession.prompt(AUTH_FAULT_RECOVERY_BODY);
1436
+ await authFaultSession.prompt(formatCard('recovery', { reason: 'auth' }, AUTH_FAULT_RECOVERY_BODY));
1435
1437
  }
1436
1438
  catch (err) {
1437
1439
  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,14 @@ 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 deliverable user message: either a person's verbatim words or the whole
29
+ * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
30
+ * only as a whole message, so mixing them attributes the card to the person. */
31
+ export interface InboxDelivery {
32
+ kind: 'human' | 'card';
33
+ text: string;
34
+ entries: readonly BrokerInboxEntry[];
35
+ }
36
+ /** Split unread entries into ordered deliveries: each human entry verbatim, and
37
+ * every node/system entry in one card landing where the first such sender fell. */
38
+ 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,52 @@ 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;
151
+ }
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
+ };
147
159
  }
148
- /** Render the existing inbox digest wording with daemon-projected report existence. */
160
+ /** Split unread entries into ordered deliveries: each human entry verbatim, and
161
+ * every node/system entry in one card landing where the first such sender fell. */
149
162
  export function coalesceBrokerInbox(entries, reportNodes) {
150
163
  if (entries.length === 0)
151
- return '(inbox empty)';
164
+ return [];
152
165
  const groups = new Map();
153
166
  for (const entry of entries) {
154
167
  const key = entry.from ?? 'system';
@@ -156,21 +169,35 @@ export function coalesceBrokerInbox(entries, reportNodes) {
156
169
  groups.set(key, []);
157
170
  groups.get(key).push(entry);
158
171
  }
159
- let hasCanonicalRef = false;
172
+ const deliveries = [];
160
173
  const sections = [];
174
+ let cardSlot = -1;
161
175
  for (const [sender, items] of groups) {
162
176
  if (sender === 'human') {
163
177
  for (const entry of items) {
164
178
  const body = inlineBody(entry);
165
- sections.push(body === '' ? entry.label : body);
179
+ deliveries.push({ kind: 'human', text: body === '' ? entry.label : body, entries: [entry] });
166
180
  }
167
181
  continue;
168
182
  }
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')}`);
183
+ if (cardSlot < 0) {
184
+ cardSlot = deliveries.length;
185
+ deliveries.push({ kind: 'card', text: '', entries: [] });
186
+ }
187
+ // The fallback to a CURRENT projected name is correct only for legacy
188
+ // entries, which predate the producer's own `from_name` snapshot.
189
+ const name = items.map((item) => item.from_name).filter((value) => value !== undefined && value !== '').at(-1)
190
+ ?? reportNodes.get(sender)?.name;
191
+ sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map((entry) => cardEntry(entry, reportNodes)) });
192
+ }
193
+ if (cardSlot >= 0) {
194
+ // Card sections group by sender, so its entry list comes from the input
195
+ // array instead: a delivery's entry ids must be in physical inbox order.
196
+ deliveries[cardSlot] = {
197
+ kind: 'card',
198
+ text: formatInboxCard(sections),
199
+ entries: entries.filter((entry) => (entry.from ?? 'system') !== 'human'),
200
+ };
173
201
  }
174
- const digest = sections.join('\n\n');
175
- return hasCanonicalRef ? `${digest}\n\nDereference a ref with \`crtr canvas history read <ref>\`.` : digest;
202
+ return deliveries;
176
203
  }
@@ -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 });
@@ -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;
@@ -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);
@@ -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. */
@@ -5,7 +5,8 @@
5
5
  // round-trip. Resuming a saved conversation needs none of this (the
6
6
  // conversation already holds the context).
7
7
  //
8
- // Layout (the framing a revived node sees):
8
+ // Layout (the framing a revived node sees), all of it inside one
9
+ // <runtime kind="revive" …> card envelope:
9
10
  // <roadmap file=…>…</roadmap> its evolving plan — the source of truth
10
11
  // <context-dir path=…>…</context-dir> what artifacts exist on disk
11
12
  // <feed>Awaiting N nodes … digest</feed> live wait state + unread/catch-up feed
@@ -25,8 +26,10 @@ import { readRoadmap, roadmapPath } from './roadmap.js';
25
26
  import { buildWakeBearings } from './bearings.js';
26
27
  import { personaDrift, commitPersonaAck } from './persona.js';
27
28
  import { readInboxSince, readCursor, writeCursor, coalesce, } from '../feed/inbox.js';
28
- import { REVIVE_KICKOFF_SENTINEL } from '../../shared/generated-context.js';
29
- export { REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, } from '../../shared/generated-context.js';
29
+ import { formatCard } from '../../shared/generated-context.js';
30
+ /** The runtime-restart continuation exactly as delivered. `revive.ts` injects
31
+ * it as the prompt of a strict resume that follows a cleanly aborted turn. */
32
+ export const RUNTIME_RESTART_CONTINUATION = formatCard('restart-continuation', {}, 'continue');
30
33
  // ---------------------------------------------------------------------------
31
34
  // Companion context files: the goal (the spawning mandate) and the one-shot
32
35
  // yield message (a note from the prior self to the revived self).
@@ -63,9 +66,6 @@ export function captureGoalIfAbsent(nodeId, text) {
63
66
  writeGoal(nodeId, body);
64
67
  return true;
65
68
  }
66
- /** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
67
- * The goal-capture extension and attached renderers share its pure definition,
68
- * so the same exact contract controls both prompt exclusion and display. */
69
69
  /** The yield-message file — a short note `crtr node yield` records for the next
70
70
  * fresh/cycling revive ("on wake, do X"). It remains durable through failed
71
71
  * launch attempts and is cleared only when session_start confirms a boot. */
@@ -115,7 +115,9 @@ export function drainBearings(meta) {
115
115
  let unreadDigest = null;
116
116
  if (entries.length > 0) {
117
117
  writeCursor(nodeId, entries.at(-1).entry_id);
118
- unreadDigest = coalesce(entries);
118
+ // The launch-argv seam carries one message, so a mixed batch's split parts
119
+ // join back into this block's body; the kickoff is one whole envelope.
120
+ unreadDigest = coalesce(entries).map((delivery) => delivery.text).join('\n\n');
119
121
  }
120
122
  // Capture + commit any external persona drift (the second of the two delivery
121
123
  // sites). Committing the ack here is the mutation; the guidance is surfaced by
@@ -244,16 +246,15 @@ function backgroundJobsBlock(nodeId) {
244
246
  export function buildReviveKickoff(meta, bearings, wakeReason) {
245
247
  const nodeId = meta.node_id;
246
248
  const parts = [
247
- `${REVIVE_KICKOFF_SENTINEL} — your previous in-memory ` +
249
+ 'You have been revived fresh after a context refresh — your previous in-memory ' +
248
250
  'context is gone, by design. Everything below was just read from disk; it is your ' +
249
251
  'full bearings. Rebuild from it and continue toward your goal.',
250
252
  ];
251
253
  // Wake provenance (Invariant B/D): when a scheduled bare self-alarm fired this
252
254
  // revive, the <crtr-wake> block reframes the generic "you were revived" above
253
- // into "a TIMER woke you" — placed right after the sentinel (so the kickoff
254
- // still STARTS with REVIVE_KICKOFF_SENTINEL, which goal-capture keys on) and
255
- // before the roadmap/disk bearings, so "why you woke" precedes "what to rebuild
256
- // from". Only the daemon's bare-wake branch passes wakeReason.
255
+ // into "a TIMER woke you" — placed before the roadmap/disk bearings, so "why you
256
+ // woke" precedes "what to rebuild from". Only the daemon's bare-wake branch
257
+ // passes wakeReason.
257
258
  if (wakeReason !== undefined)
258
259
  parts.push(buildWakeBearings(wakeReason));
259
260
  // The roadmap is the source of truth on a fresh revive: its frozen core holds
@@ -316,5 +317,14 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
316
317
  if (bearings.driftGuidance !== null) {
317
318
  parts.push(`<persona-transition>\nYour role was changed while you were away. ${bearings.driftGuidance}\n</persona-transition>`);
318
319
  }
319
- return parts.join('\n\n');
320
+ // The whole kickoff is one runtime card. Its facts are what this producer
321
+ // knows about the revive, and they are the ONLY thing a reader may classify or
322
+ // summarize on — the prose above stays free to be reworded. The envelope is
323
+ // also what keeps a fresh revive out of canvas-goal-capture's first-real-input
324
+ // path, which would otherwise overwrite a bare root's mandate and rename it.
325
+ return formatCard('revive', {
326
+ mode: meta.mode,
327
+ ...(meta.cycle_pending === true ? { cycle: true } : {}),
328
+ ...(wakeReason === undefined ? {} : { wake: wakeReason.kind }),
329
+ }, `\n${parts.join('\n\n')}\n`);
320
330
  }
@@ -30,6 +30,8 @@ export interface NodeSnapshotRead {
30
30
  }
31
31
  export interface NodeMessagesPageRead {
32
32
  messages: unknown[];
33
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
34
+ messageIds?: string[];
33
35
  nextCursor: string | null;
34
36
  }
35
37
  export interface NodeMessagesPageOptions {
@@ -153,10 +153,15 @@ async function reconstructVisibleMessages(nodeId) {
153
153
  copyFileSync(sessionFile, copy);
154
154
  const manager = SessionManager.open(copy);
155
155
  const context = manager.buildSessionContext();
156
- const messages = visibleMessages(cycleAwareMessages(manager), { boundaryReviewId: node.review_binding?.review_id });
156
+ const reconstructed = cycleAwareMessages(manager);
157
+ const identified = reconstructed === undefined
158
+ ? undefined
159
+ : visibleMessages(reconstructed, { boundaryReviewId: node.review_binding?.review_id });
160
+ const messages = identified?.map(({ message }) => message) ?? context.messages;
157
161
  return {
158
162
  sessionFile,
159
163
  messages,
164
+ identified,
160
165
  model: context.model,
161
166
  thinkingLevel: context.thinkingLevel,
162
167
  sessionId: manager.getSessionId(),
@@ -208,6 +213,7 @@ export async function readNodeSnapshot(nodeId) {
208
213
  const read = await reconstructVisibleMessages(nodeId);
209
214
  const snapshot = {
210
215
  messages: read.messages,
216
+ ...(read.identified === undefined ? {} : { messageIds: read.identified.map(({ id }) => id) }),
211
217
  stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
212
218
  // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
213
219
  // live-only fields report their at-rest values (nothing is streaming or
@@ -255,11 +261,13 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
255
261
  next: 'Use a limit from 1 through 500.',
256
262
  });
257
263
  }
258
- const { messages } = await reconstructVisibleMessages(nodeId);
264
+ const { messages, identified } = await reconstructVisibleMessages(nodeId);
259
265
  const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
260
266
  const start = Math.max(0, before - limit);
267
+ const page = identified?.slice(start, before);
261
268
  return {
262
- messages: messages.slice(start, before),
269
+ messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
270
+ ...(page === undefined ? {} : { messageIds: page.map(({ id }) => id) }),
263
271
  nextCursor: start === 0 ? null : encodeMessagesCursor(start),
264
272
  };
265
273
  }
@@ -8,6 +8,11 @@ export declare const CRTR_CYCLE_CUSTOM_TYPE = "crtr-cycle";
8
8
  * snapshot rendering. Never persisted to the session file. */
9
9
  export declare const CRTR_CYCLE_DIVIDER_CUSTOM_TYPE = "crtr-cycle-divider";
10
10
  type SnapshotMessage = BrokerSnapshot['messages'][number];
11
+ /** One reconstructed message and the durable session entry that produced it. */
12
+ export type IdentifiedMessage = {
13
+ id: string;
14
+ message: SnapshotMessage;
15
+ };
11
16
  /** The structural slice of pi's SessionManager cycle rendering needs. The
12
17
  * broker's fake-engine test fixture implements only `buildSessionContext`, so
13
18
  * the tree accessors are optional — absent ⇒ single-cycle behavior. */
@@ -36,5 +41,5 @@ export declare function withoutYieldAbort(message: SnapshotMessage): SnapshotMes
36
41
  * the manager lacks tree accessors (fake engine), or when the current leaf was
37
42
  * tree-navigated back into a pre-yield branch (pi's active-branch semantics
38
43
  * then apply unchanged). */
39
- export declare function cycleAwareMessages(sm: CycleSessionManagerLike): SnapshotMessage[];
44
+ export declare function cycleAwareMessages(sm: CycleSessionManagerLike): IdentifiedMessage[] | undefined;
40
45
  export {};