@north-light/crouter 0.3.269 → 0.3.271

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 (78) hide show
  1. package/dist/api/dto/broker.d.ts +11 -2
  2. package/dist/api/dto/broker.js +2 -2
  3. package/dist/api/dto/nodes.d.ts +4 -0
  4. package/dist/commands/pkg/plugin-inspect.js +2 -2
  5. package/dist/commands/pkg/plugin-manage.js +3 -3
  6. package/dist/commands/sys/doctor.js +4 -3
  7. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +2 -2
  8. package/dist/core/__tests__/helpers/harness.js +1 -0
  9. package/dist/core/__tests__/human-deliver.test.js +1 -1
  10. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  11. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  12. package/dist/core/__tests__/integration/revive.test.js +15 -15
  13. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  14. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  15. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  16. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  17. package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -0
  18. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  19. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  20. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  21. package/dist/core/command-hooks/discovery.d.ts +27 -1
  22. package/dist/core/command-hooks/discovery.js +52 -0
  23. package/dist/core/command-hooks/index.d.ts +4 -3
  24. package/dist/core/command-hooks/index.js +2 -1
  25. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  26. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  27. package/dist/core/command-hooks/report.d.ts +11 -2
  28. package/dist/core/command-hooks/report.js +10 -1
  29. package/dist/core/command-hooks/schema.d.ts +16 -1
  30. package/dist/core/command-hooks/schema.js +128 -40
  31. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  32. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  33. package/dist/core/human/feedback-companion.js +1 -1
  34. package/dist/core/review/realize.js +1 -1
  35. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  36. package/dist/core/runtime/broker/engine-drive.js +29 -1
  37. package/dist/core/runtime/broker/frame-client.js +3 -4
  38. package/dist/core/runtime/broker/rebind.js +7 -0
  39. package/dist/core/runtime/broker-protocol.d.ts +11 -2
  40. package/dist/core/runtime/broker.d.ts +1 -1
  41. package/dist/core/runtime/broker.js +13 -3
  42. package/dist/core/runtime/fleet.d.ts +5 -6
  43. package/dist/core/runtime/node-read.d.ts +2 -0
  44. package/dist/core/runtime/node-read.js +18 -9
  45. package/dist/core/runtime/nodes.js +6 -1
  46. package/dist/core/runtime/revive-all.d.ts +1 -1
  47. package/dist/core/runtime/revive-all.js +2 -2
  48. package/dist/core/runtime/revive.d.ts +2 -2
  49. package/dist/core/runtime/revive.js +49 -13
  50. package/dist/core/runtime/session-visibility.d.ts +10 -14
  51. package/dist/core/runtime/session-visibility.js +43 -30
  52. package/dist/core/runtime/stamp/channel.d.ts +10 -6
  53. package/dist/core/runtime/stamp/channel.js +16 -7
  54. package/dist/core/runtime/stamp/protocol.d.ts +4 -0
  55. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  56. package/dist/core/runtime/turn-visibility.js +31 -0
  57. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  58. package/dist/daemon/api/bridge.js +1 -1
  59. package/dist/daemon/api/handlers/attach.js +4 -4
  60. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  61. package/dist/daemon/api/handlers/messages.js +2 -2
  62. package/dist/daemon/api/handlers/nodes.js +7 -4
  63. package/dist/daemon/cron/sinks.js +3 -3
  64. package/dist/daemon/fleet.js +10 -22
  65. package/dist/daemon/messaging/node-message.js +1 -1
  66. package/dist/daemon/profile-delete.js +1 -1
  67. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  68. package/dist/hook-authoring.d.ts +31 -1
  69. package/dist/hook-authoring.js +42 -5
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +3 -0
  72. package/dist/pi-extensions/canvas-stamp.js +3 -3
  73. package/dist/shared/env.d.ts +2 -0
  74. package/dist/shared/env.js +4 -0
  75. package/dist/shared/generated-context.d.ts +1 -2
  76. package/dist/shared/generated-context.js +3 -4
  77. package/package.json +1 -1
  78. package/runtime.lock.json +2 -2
@@ -0,0 +1,152 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { PROCESS_GROUP_KILL_GRACE_MS, posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
4
+ import { isRecord } from '../../../shared/predicates.js';
5
+ import { diag } from '../../io.js';
6
+ const MAX_STDOUT = 10 * 1024 * 1024;
7
+ export const LIFECYCLE_HOOK_TIMEOUT_MS = 5_000;
8
+ export const LIFECYCLE_EVENT_BUDGET_MS = 10_000;
9
+ /** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
10
+ export async function invokeLifecycleHook(hook, invocation, timeoutMs) {
11
+ try {
12
+ const request = {
13
+ protocolVersion: 1,
14
+ op: hook.op,
15
+ event: hook.event,
16
+ phase: hook.phase,
17
+ operationId: randomBytes(16).toString('hex'),
18
+ node: invocation.node,
19
+ runtime: invocation.runtime,
20
+ context: { cwd: invocation.node.cwd },
21
+ };
22
+ const result = await runLifecycleProcess(hook, JSON.stringify(request), timeoutMs);
23
+ if (result.signal !== null)
24
+ throw new Error(`executable was killed by signal ${result.signal}`);
25
+ const envelope = parseEnvelope(result.stdout);
26
+ if (envelope.ok) {
27
+ if (result.status !== 0)
28
+ diag(`crtr: ${label(hook)} returned ok:true but exited ${result.status} (honoring the envelope)`);
29
+ return;
30
+ }
31
+ diag(`crtr: ${label(hook)} failed: ${envelope.error.code}: ${envelope.error.message}`);
32
+ }
33
+ catch (error) {
34
+ diag(`crtr: ${label(hook)} failed: ${describeError(error)}`);
35
+ }
36
+ }
37
+ /** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
38
+ export async function invokeLifecycleHooks(hooks, invocation) {
39
+ const deadline = Date.now() + LIFECYCLE_EVENT_BUDGET_MS;
40
+ for (const hook of hooks) {
41
+ const remaining = deadline - Date.now();
42
+ // A timed-out hook must be reaped before launch can proceed. Reserve the
43
+ // process-group termination grace inside the event budget rather than
44
+ // letting the final cleanup extend the node-start critical path.
45
+ const timeoutMs = Math.min(LIFECYCLE_HOOK_TIMEOUT_MS, remaining - PROCESS_GROUP_KILL_GRACE_MS);
46
+ if (timeoutMs <= 0) {
47
+ diag(`crtr: lifecycle hook event ${hook.event} exceeded its ${LIFECYCLE_EVENT_BUDGET_MS}ms aggregate budget; skipped ${label(hook)}`);
48
+ return;
49
+ }
50
+ await invokeLifecycleHook(hook, invocation, timeoutMs);
51
+ }
52
+ }
53
+ async function runLifecycleProcess(hook, input, timeoutMs) {
54
+ let child;
55
+ try {
56
+ child = spawn(hook.executable, ['--crtr-hook-protocol', '1'], {
57
+ cwd: hook.plugin.root,
58
+ env: process.env,
59
+ stdio: ['pipe', 'pipe', 'inherit'],
60
+ ...posixProcessGroupSpawnOptions(),
61
+ });
62
+ }
63
+ catch (error) {
64
+ throw new Error(`executable could not be run (${describeError(error)})`);
65
+ }
66
+ return new Promise((resolve, reject) => {
67
+ let stdoutBytes = 0;
68
+ const stdout = [];
69
+ let failure;
70
+ let closed = false;
71
+ let cleaned = false;
72
+ let status = null;
73
+ let signal = null;
74
+ let timeout;
75
+ const finish = () => {
76
+ if (!closed || (failure !== undefined && !cleaned))
77
+ return;
78
+ if (timeout !== undefined)
79
+ clearTimeout(timeout);
80
+ if (failure?.kind === 'timeout')
81
+ return reject(new Error(`executable timed out after ${timeoutMs}ms`));
82
+ if (failure?.kind === 'output_limit')
83
+ return reject(new Error('stdout exceeded the 10 MiB cap'));
84
+ if (failure?.kind === 'spawn')
85
+ return reject(new Error(`executable could not be run (${describeError(failure.error)})`));
86
+ if (failure?.kind === 'cleanup')
87
+ return reject(new Error(`executable cleanup failed (${describeError(failure.error)})`));
88
+ resolve({ stdout: Buffer.concat(stdout).toString('utf8'), status, signal });
89
+ };
90
+ const terminate = (reason) => {
91
+ if (failure !== undefined)
92
+ return;
93
+ failure = reason;
94
+ if (timeout !== undefined)
95
+ clearTimeout(timeout);
96
+ void terminateProcessGroup(child).then(() => { cleaned = true; finish(); }, (error) => { failure = { kind: 'cleanup', error }; cleaned = true; finish(); });
97
+ };
98
+ child.stdout.on('data', (chunk) => {
99
+ stdoutBytes += chunk.byteLength;
100
+ if (stdoutBytes > MAX_STDOUT)
101
+ return terminate({ kind: 'output_limit' });
102
+ stdout.push(chunk);
103
+ });
104
+ child.once('error', (error) => {
105
+ if (child.pid === undefined) {
106
+ failure = { kind: 'spawn', error };
107
+ closed = true;
108
+ cleaned = true;
109
+ finish();
110
+ return;
111
+ }
112
+ terminate({ kind: 'spawn', error });
113
+ });
114
+ child.once('close', (nextStatus, nextSignal) => {
115
+ status = nextStatus;
116
+ signal = nextSignal;
117
+ closed = true;
118
+ finish();
119
+ });
120
+ timeout = setTimeout(() => terminate({ kind: 'timeout' }), timeoutMs);
121
+ child.stdin.end(input);
122
+ });
123
+ }
124
+ function parseEnvelope(stdout) {
125
+ let value;
126
+ try {
127
+ value = JSON.parse(stdout.trim());
128
+ }
129
+ catch {
130
+ throw new Error('stdout was not a single JSON envelope');
131
+ }
132
+ if (!isRecord(value) || value['protocolVersion'] !== 1)
133
+ throw new Error('stdout was not a protocolVersion: 1 envelope');
134
+ if (value['ok'] === true && exactKeys(value, ['protocolVersion', 'ok']))
135
+ return { protocolVersion: 1, ok: true };
136
+ if (value['ok'] !== false || !exactKeys(value, ['protocolVersion', 'ok', 'error']))
137
+ throw new Error('response envelope had missing or extra fields');
138
+ const error = value['error'];
139
+ if (!isRecord(error) || !exactKeys(error, ['code', 'message'], ['next']) || typeof error['code'] !== 'string' || error['code'] === '' || typeof error['message'] !== 'string' || error['message'] === '' || (error['next'] !== undefined && typeof error['next'] !== 'string')) {
140
+ throw new Error('failure envelope had invalid error fields');
141
+ }
142
+ return { protocolVersion: 1, ok: false, error: { code: error['code'], message: error['message'], ...(error['next'] === undefined ? {} : { next: error['next'] }) } };
143
+ }
144
+ function exactKeys(value, required, optional = []) {
145
+ return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
146
+ }
147
+ function label(hook) {
148
+ return `plugin "${hook.plugin.name}" lifecycle hook "${hook.op}" for ${hook.event}`;
149
+ }
150
+ function describeError(error) {
151
+ return error instanceof Error ? error.message : String(error);
152
+ }
@@ -92,7 +92,7 @@ export async function realizeFeedbackCompanion(args) {
92
92
  // The bind deadline below only a live broker can meet, so a capacity
93
93
  // refusal must surface here and discard the companion — freezing would
94
94
  // burn the whole deadline waiting for a launch that is not coming.
95
- reviveNode(node.node_id, { resume: false, capacity: 'refuse' });
95
+ await reviveNode(node.node_id, { resume: false, capacity: 'refuse' });
96
96
  if (!await awaitCompanionBind(node.node_id, COMPANION_BIND_DEADLINE_MS)) {
97
97
  throw new Error(`feedback companion did not bind within ${COMPANION_BIND_DEADLINE_MS}ms`);
98
98
  }
@@ -172,7 +172,7 @@ export async function realizeReview(reviewId) {
172
172
  // Realization is bounded by the bind deadline below, which only a live broker
173
173
  // can meet: refuse at the cap rather than freeze into a certain timeout.
174
174
  if (companion.pi_session_id === null)
175
- reviveNode(companion.node_id, { resume: false, capacity: 'refuse' });
175
+ await reviveNode(companion.node_id, { resume: false, capacity: 'refuse' });
176
176
  // R4: session_start is the durable bind boundary.
177
177
  if (!await awaitCompanionBind(review.companion_node_id, COMPANION_BIND_DEADLINE_MS)) {
178
178
  throw companionUnbound(review.review_id, COMPANION_BIND_DEADLINE_MS);
@@ -1,3 +1,4 @@
1
+ import type { MessageOrigin } from '../stamp/protocol.js';
1
2
  import type { ClientToBroker } from '../broker-protocol.js';
2
3
  import type { BrokerClient, BrokerClientRegistry } from './client-registry.js';
3
4
  import type { EventProjection } from './event-projection.js';
@@ -9,9 +10,18 @@ import type { RebindDriver } from './rebind.js';
9
10
  import type { ToolGroupTracker } from './tool-groups.js';
10
11
  import type { PromptDispatchTracker } from './turn-ignition.js';
11
12
  import type { TurnAdmissionGate } from './turn-admission.js';
12
- /** The frames that drive the live engine — the viewer's input vocabulary. */
13
- export type DriveFrame = Extract<ClientToBroker, {
14
- type: 'prompt' | 'steer' | 'follow_up' | 'abort' | 'bash';
13
+ /** The frames that drive the live engine. A daemon delivery carries its
14
+ * provenance through admission because only the resolved route can decide
15
+ * whether the resulting run is internal. */
16
+ type DeliveredInput = {
17
+ delivery?: {
18
+ origin?: MessageOrigin;
19
+ };
20
+ };
21
+ export type DriveFrame = (Extract<ClientToBroker, {
22
+ type: 'prompt' | 'follow_up';
23
+ }> & DeliveredInput) | Extract<ClientToBroker, {
24
+ type: 'steer' | 'abort' | 'bash';
15
25
  }>;
16
26
  export type EngineDriver = (client: BrokerClient, frame: DriveFrame) => Promise<unknown> | undefined;
17
27
  export declare function createEngineDriver(deps: {
@@ -26,3 +36,4 @@ export declare function createEngineDriver(deps: {
26
36
  toolGroups: ToolGroupTracker;
27
37
  turnAdmission: TurnAdmissionGate;
28
38
  }): EngineDriver;
39
+ export {};
@@ -2,6 +2,8 @@ import { emitEvent } from '../../events/emit.js';
2
2
  import { operationIdContext } from '../../events/operation-id.js';
3
3
  import { promptWithAdvertisedCommandInvocation } from '../advertised-command-invocation.js';
4
4
  import { heldDeferredInboxPromptJoin } from './held-deferred-inbox.js';
5
+ import { announceMessageOrigin } from '../stamp/channel.js';
6
+ import { clearTurnVisibility, currentTurnVisibility, setTurnVisibility, } from '../turn-visibility.js';
5
7
  import { chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, resolveEngineRoute, resolveSteerCall, } from './engine-routing.js';
6
8
  export function createEngineDriver(deps) {
7
9
  const { registry, replies, session, dispatches, memoryRefs, authReloadGate, rebind, projection, toolGroups, turnAdmission, } = deps;
@@ -47,6 +49,20 @@ export function createEngineDriver(deps) {
47
49
  const route = resolveEngineRoute(frame, session().isStreaming);
48
50
  const promptOptions = route.call === 'prompt' ? route.options : undefined;
49
51
  const guidance = memoryRefs.guidanceFor(frame.text);
52
+ const deliveryIsInternal = frame.delivery?.origin?.type === 'runtime'
53
+ && frame.delivery.origin.kind === 'park'
54
+ && route.call === 'prompt'
55
+ && route.options.streamingBehavior === undefined;
56
+ const announceDelivery = () => {
57
+ if (frame.delivery === undefined)
58
+ return;
59
+ const visibility = deliveryIsInternal ? 'internal' : undefined;
60
+ if (frame.delivery.origin === undefined) {
61
+ setTurnVisibility('visible');
62
+ return;
63
+ }
64
+ announceMessageOrigin(frame.text, frame.delivery.origin, visibility);
65
+ };
50
66
  const prompt = async () => {
51
67
  // Both human surfaces converge at this admitted boundary. The watcher
52
68
  // claims held deferred mail before the prompt body, but its cursor stays
@@ -60,7 +76,19 @@ export function createEngineDriver(deps) {
60
76
  // replay. A person's live prompt is not replayable, so it must run.
61
77
  }
62
78
  }
63
- await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
79
+ announceDelivery();
80
+ registry.broadcast({
81
+ type: 'turn_visibility',
82
+ visibility: currentTurnVisibility(),
83
+ });
84
+ try {
85
+ await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
86
+ }
87
+ catch (error) {
88
+ if (deliveryIsInternal)
89
+ clearTurnVisibility();
90
+ throw error;
91
+ }
64
92
  };
65
93
  if (guidance === null) {
66
94
  if (route.call === 'prompt') {
@@ -1,7 +1,6 @@
1
1
  import { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../../shared/generated-context.js';
2
2
  import { emitEvent } from '../../events/emit.js';
3
3
  import { normalizeSnapshotTail } from '../broker-protocol.js';
4
- import { announceMessageOrigin } from '../stamp/channel.js';
5
4
  export function createClientHandlers(deps) {
6
5
  const { registry, replies, session, driveEngine, turnAcceptance, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, disposeAndExit, } = deps;
7
6
  const { notWritable, ackTo } = replies;
@@ -83,13 +82,13 @@ export function createClientHandlers(deps) {
83
82
  };
84
83
  if (via === 'prompt')
85
84
  turnAcceptance.waitFor(ackAccepted);
86
- // The daemon knows who authored this body; the stamp writer lives here.
87
- if (frame.origin !== undefined)
88
- announceMessageOrigin(frame.text, frame.origin);
89
85
  const outcome = driveEngine(client, {
90
86
  type: 'prompt',
91
87
  text: frame.text,
92
88
  ...(frame.images !== undefined ? { images: frame.images } : {}),
89
+ delivery: {
90
+ ...(frame.origin === undefined ? {} : { origin: frame.origin }),
91
+ },
93
92
  });
94
93
  if (via === 'steer' || outcome === undefined) {
95
94
  turnAcceptance.cancel(ackAccepted);
@@ -6,6 +6,7 @@ import { operationIdContext } from '../../events/operation-id.js';
6
6
  import { installPersonaTransitionGate } from '../broker-persona-guidance.js';
7
7
  import { abortSessionFromExtension } from './extension-abort.js';
8
8
  import { clearFault, invalidateProviderRetryEpisode, readFault } from '../fault.js';
9
+ import { clearTurnVisibility } from '../turn-visibility.js';
9
10
  const MAX_ADMITTED_REPLACEMENTS = 2;
10
11
  const REPLACEMENT_DEADLINE_MS = 30_000;
11
12
  const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
@@ -302,6 +303,12 @@ export class RebindDriver {
302
303
  catch (err) {
303
304
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `event relay threw: ${String(err)}` } });
304
305
  }
306
+ finally {
307
+ // Projection sees the settlement while the run is still internal; every
308
+ // later input and output fails open to visible after this boundary.
309
+ if (event.type === 'agent_settled')
310
+ clearTurnVisibility();
311
+ }
305
312
  }
306
313
  ackTo(client, op, ok = true, detail, id) {
307
314
  this.deps.registry.sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}), ...(id !== undefined ? { id } : {}) });
@@ -2,7 +2,7 @@ import type { AgentSession, AgentSessionEvent, RpcExtensionUIRequest, RpcExtensi
2
2
  import type { Api, ImageContent, Model } from '@earendil-works/pi-ai';
3
3
  import type { BrokerErrorFrame, BrokerNodeNamedFrame } from '../../api/dto/broker.js';
4
4
  import type { ToolGroupSummary } from '../../shared/tool-groups.js';
5
- import type { MessageOrigin } from './stamp/protocol.js';
5
+ import type { MessageOrigin, MessageVisibility } from './stamp/protocol.js';
6
6
  /** pi's own RPC types, re-exported unchanged. `broker-protocol` stays the one
7
7
  * import site for the wire union; the shapes themselves are pi's, versioned by
8
8
  * pi, so a pi change is a compile error here instead of silent drift. */
@@ -22,6 +22,10 @@ export interface BrokerSnapshot {
22
22
  /** Stable session-entry ids aligned 1:1 with `messages`. Absent when the
23
23
  * session manager cannot expose its entry tree. */
24
24
  messageIds?: string[];
25
+ /** Presentation visibility aligned 1:1 with `messages`. */
26
+ messageVisibility: MessageVisibility[];
27
+ /** Presentation visibility of the run currently owned by the engine. */
28
+ turnVisibility: MessageVisibility;
25
29
  stats: SessionStats;
26
30
  /** pi's OWN `get_state` payload, verbatim — not a mirror. `buildSnapshot`
27
31
  * fills it exactly as pi's `rpc-mode.js` does, from the same getters, so a
@@ -398,6 +402,11 @@ export interface WelcomeFrame {
398
402
  * the other four learn nothing); the PAYLOAD is pi's — `Model`, exactly what
399
403
  * pi's own `set_model` response carries and what `snapshot.state.model` holds.
400
404
  * Undefined when the engine has none. */
405
+ export interface TurnVisibilityFrame {
406
+ type: 'turn_visibility';
407
+ /** Presentation visibility of the run whose native events follow. */
408
+ visibility: MessageVisibility;
409
+ }
401
410
  export interface WorkingActivityFrame {
402
411
  type: 'working_activity';
403
412
  /** Full display label selected from the user's configured gerunds. */
@@ -643,7 +652,7 @@ export interface ToolGroupSummaryFrame {
643
652
  /** Everything the broker can send. Live `AgentSessionEvent`s are relayed
644
653
  * verbatim (the broker adds nothing); the broker's own control frames carry
645
654
  * non-colliding `type` discriminants. */
646
- export type BrokerToClient = WelcomeFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
655
+ export type BrokerToClient = WelcomeFrame | TurnVisibilityFrame | WorkingActivityFrame | ModelChangedFrame | ResourcesReloadedFrame | ErrorFrame | NodeNamedFrame | AckFrame | BrokerDataFrame | BashStartFrame | BashOutputFrame | BashEndFrame | ExtensionUIRequestFrame | ExtensionUIDismissFrame | ToolGroupSummaryFrame | AgentSessionEvent;
647
656
  /** Encode one frame as a single newline-terminated JSON line. */
648
657
  export declare function encodeFrame(frame: ClientToBroker | BrokerToClient): string;
649
658
  /** Byte bounds for a {@link FrameDecoder} (C5). */
@@ -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): import("./session-cycles.js").IdentifiedMessage[] | undefined;
32
+ export declare function snapshotMessages(session: CreateAgentSessionResult['session'], boundaryReviewId?: string): import("./session-visibility.js").ProjectedMessage[] | undefined;
33
33
  export declare function buildBrokerSession(engine: BrokerEngine, cfg: BrokerSdkConfig): Promise<{
34
34
  session: CreateAgentSessionResult['session'];
35
35
  services: AgentSessionServices;
@@ -40,7 +40,8 @@ import { modelRequestFromConfig, selectInitialModelRoute, unregisteredConcreteMo
40
40
  import { KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
41
41
  import { probeOnline as probeOnlineReal } from './connectivity.js';
42
42
  import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
43
- import { visibleMessages } from './session-visibility.js';
43
+ import { projectTranscript } from './session-visibility.js';
44
+ import { currentTurnVisibility } from './turn-visibility.js';
44
45
  import { isUnknownModel } from './model-selection.js';
45
46
  import { BrokerClientRegistry } from './broker/client-registry.js';
46
47
  import { ToolGroupTracker } from './broker/tool-groups.js';
@@ -397,6 +398,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
397
398
  return ({
398
399
  messages,
399
400
  ...(retained === undefined ? {} : { messageIds: retained.map(({ id }) => id) }),
401
+ messageVisibility: retained?.map(({ visibility }) => visibility)
402
+ ?? messages.map(() => 'visible'),
403
+ turnVisibility: currentTurnVisibility(),
400
404
  stats: liveSession.getSessionStats(),
401
405
  // pi's `get_state` (rpc-mode.js), field for field, from the same getters.
402
406
  // The ONE divergence: a model-less session (undefined OR the SDK
@@ -754,11 +758,17 @@ export function snapshotMessages(session, boundaryReviewId) {
754
758
  // payload), narrower than `CycleSessionManagerLike`'s structural read of the
755
759
  // `crtr-cycle` marker's `{cycle, fromLeaf}` shape — the fields we read off it
756
760
  // are ones ONLY this module's own `appendCustomEntry` call (above) ever writes.
757
- const messages = cycleAwareMessages(session.sessionManager);
761
+ const manager = session.sessionManager;
762
+ const messages = cycleAwareMessages(manager);
758
763
  if (messages !== undefined) {
759
764
  hydratePersistedAdvertisedCommandMessages(messages.map(({ message }) => message));
760
765
  }
761
- return messages === undefined ? undefined : visibleMessages(messages, { boundaryReviewId });
766
+ return messages === undefined
767
+ ? undefined
768
+ : projectTranscript(messages, {
769
+ boundaryReviewId,
770
+ sessionEntries: manager.getEntries?.(),
771
+ });
762
772
  }
763
773
  // resolveLaunchModel — the SOLE launch model-selection policy, module-scoped
764
774
  // so both buildForManager (initial boot, inside buildBrokerSession below) and
@@ -47,9 +47,8 @@ export interface FleetRegistry {
47
47
  get(nodeId: string): FleetEntry | undefined;
48
48
  /** The capacity count: registered brokers only, excluding reservations. */
49
49
  size(): number;
50
- /** Every slot the cap accounts for — registered brokers plus in-flight
51
- * reservations (a launch that has claimed a slot but not yet registered, and
52
- * an exited broker whose exit-policy job has not yet run). */
50
+ /** Every slot the cap accounts for — registered brokers plus launch
51
+ * reservations that have not registered yet. */
53
52
  occupancy(): number;
54
53
  /** Claim a slot for `nodeId`. True means the caller may launch; false means
55
54
  * the cap is reached and the caller must freeze or refuse. Idempotent — a
@@ -62,9 +61,9 @@ export interface FleetRegistry {
62
61
  /** Drop an entry without policy (terminal exits + close paths). */
63
62
  forget(nodeId: string): void;
64
63
  /** Wired by `headlessBrokerHost.launch` to the handle's `exited` promise.
65
- * Removes the entry (freeing the capacity slot), then enqueues an
66
- * exit-policy job on the daemon's serial work lane — policy never runs
67
- * inline on the Node event callback (design D-3). */
64
+ * Removes the entry (freeing the capacity slot), then enqueues its
65
+ * observation on the daemon's serial work lane — it never runs inline on
66
+ * the Node event callback (design D-3). */
68
67
  onChildExit(nodeId: string, status: FleetExitStatus): void;
69
68
  /** Test seam — delivers a synthetic exit through the SAME path as
70
69
  * onChildExit, so fabricated-row tests drive policy without a process. */
@@ -32,6 +32,8 @@ export interface NodeMessagesPageRead {
32
32
  messages: unknown[];
33
33
  /** Stable session-entry ids aligned 1:1 with `messages`. */
34
34
  messageIds?: string[];
35
+ /** Presentation visibility aligned 1:1 with `messages`. */
36
+ messageVisibility: Array<'visible' | 'internal'>;
35
37
  nextCursor: string | null;
36
38
  }
37
39
  export interface NodeMessagesPageOptions {
@@ -18,7 +18,7 @@ import { SessionManager } from './broker-sdk.js';
18
18
  import { openModelRegistry } from './model-registry.js';
19
19
  import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
20
20
  import { cycleAwareMessages } from './session-cycles.js';
21
- import { visibleMessages } from './session-visibility.js';
21
+ import { projectTranscript } from './session-visibility.js';
22
22
  import { normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
23
23
  /** A session `.jsonl` records only `{ provider, modelId }` — pi never persists a
24
24
  * whole `Model`. Resolve it back through pi's own registry so a dormant
@@ -149,10 +149,10 @@ function readToolGroupSummaries(nodeId) {
149
149
  return {};
150
150
  }
151
151
  }
152
- /** Reconstruct the visible message history from a stable at-rest session copy.
152
+ /** Reconstruct transcript messages from a stable at-rest session copy.
153
153
  * Both snapshot and paged reads use this one path so their histories cannot
154
154
  * diverge. */
155
- async function reconstructVisibleMessages(nodeId) {
155
+ async function reconstructTranscript(nodeId) {
156
156
  const node = getNode(nodeId);
157
157
  if (node === null) {
158
158
  throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, field: 'id', next: 'List nodes with `crtr node inspect list`.' });
@@ -174,7 +174,10 @@ async function reconstructVisibleMessages(nodeId) {
174
174
  }
175
175
  const identified = reconstructed === undefined
176
176
  ? undefined
177
- : visibleMessages(reconstructed, { boundaryReviewId: node.review_binding?.review_id });
177
+ : projectTranscript(reconstructed, {
178
+ boundaryReviewId: node.review_binding?.review_id,
179
+ sessionEntries: manager.getEntries(),
180
+ });
178
181
  const messages = identified?.map(({ message }) => message) ?? context.messages;
179
182
  return {
180
183
  sessionFile,
@@ -228,10 +231,13 @@ function encodeMessagesCursor(before) {
228
231
  /** Reconstruct a persisted session without launching its broker. Backs the
229
232
  * machine-readable snapshot endpoint and the human transcript render. */
230
233
  export async function readNodeSnapshot(nodeId) {
231
- const read = await reconstructVisibleMessages(nodeId);
234
+ const read = await reconstructTranscript(nodeId);
232
235
  const snapshot = {
233
236
  messages: read.messages,
234
237
  ...(read.identified === undefined ? {} : { messageIds: read.identified.map(({ id }) => id) }),
238
+ messageVisibility: read.identified?.map(({ visibility }) => visibility)
239
+ ?? read.messages.map(() => 'visible'),
240
+ turnVisibility: 'visible',
235
241
  stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
236
242
  // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
237
243
  // live-only fields report their at-rest values (nothing is streaming or
@@ -284,15 +290,16 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
284
290
  // transcript read is how a UI opens a new conversation. Only this paged read
285
291
  // can answer honestly — `/snapshot` would have to invent the engine state a
286
292
  // never-run node does not have, so it and the raw `/session` export still
287
- // reject the same state through `reconstructVisibleMessages`.
293
+ // reject the same state through `reconstructTranscript`.
288
294
  // Same shape a session-file-backed empty read produces: `messageIds` stays
289
295
  // aligned 1:1 with `messages` (an absent `message_ids` would claim this read
290
296
  // cannot identify entries, which is a different statement than "there are
291
297
  // none"), and `nextCursor: null` is the same end-of-history marker an
292
298
  // exhausted backward walk returns.
293
- if (readableSessionFile(nodeId) === null)
294
- return { messages: [], messageIds: [], nextCursor: null };
295
- const { messages, identified } = await reconstructVisibleMessages(nodeId);
299
+ if (readableSessionFile(nodeId) === null) {
300
+ return { messages: [], messageIds: [], messageVisibility: [], nextCursor: null };
301
+ }
302
+ const { messages, identified } = await reconstructTranscript(nodeId);
296
303
  const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
297
304
  let start = Math.max(0, before - limit);
298
305
  while (start > 0
@@ -305,6 +312,8 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
305
312
  return {
306
313
  messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
307
314
  ...(page === undefined ? {} : { messageIds: page.map(({ id }) => id) }),
315
+ messageVisibility: page?.map(({ visibility }) => visibility)
316
+ ?? messages.slice(start, before).map(() => 'visible'),
308
317
  nextCursor: start === 0 ? null : encodeMessagesCursor(start),
309
318
  };
310
319
  }
@@ -12,7 +12,7 @@
12
12
  // learns when the work finishes — this seeds the subscription
13
13
  // graph to mirror the spawn structure. A `spawned_by` audit edge
14
14
  // is also recorded.
15
- import { envHomeOverride } from '../../shared/env.js';
15
+ import { envHomeOverride, envRuntimeHomeOverride } from '../../shared/env.js';
16
16
  import { randomBytes } from 'node:crypto';
17
17
  import { mkdirSync } from 'node:fs';
18
18
  import { createNode, getNode, getRow, subscribe, recordSpawn, subscribersOf, contextDir, } from '../canvas/index.js';
@@ -206,6 +206,11 @@ export function nodeEnv(meta) {
206
206
  const home = envHomeOverride();
207
207
  if (home !== undefined && home !== '')
208
208
  env['CRTR_HOME'] = home;
209
+ // Propagate an explicit runtime home so broker-side `crtr` invocations use
210
+ // the same installed generation despite an isolated HOME.
211
+ const runtimeHome = envRuntimeHomeOverride();
212
+ if (runtimeHome !== undefined && runtimeHome !== '')
213
+ env['CRTR_RUNTIME_HOME'] = runtimeHome;
209
214
  // Propagate the subtree-root node id. The broker uses it to group a subtree;
210
215
  // each launch site also emits the authoritative rootOfSpine(nodeId) over this
211
216
  // passthrough.
@@ -54,4 +54,4 @@ export interface ReviveAllResult {
54
54
  * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
55
55
  * with resume:true. One failing node never aborts the sweep — its error is
56
56
  * collected and the rest proceed. */
57
- export declare function reviveAll(): ReviveAllResult;
57
+ export declare function reviveAll(): Promise<ReviveAllResult>;
@@ -84,7 +84,7 @@ export function listDisconnected() {
84
84
  * no-op), so this is a thin orchestrator over `listDisconnected`: revive each
85
85
  * with resume:true. One failing node never aborts the sweep — its error is
86
86
  * collected and the rest proceed. */
87
- export function reviveAll() {
87
+ export async function reviveAll() {
88
88
  const result = { revived: [], failed: [], frozen: [] };
89
89
  for (const meta of listDisconnected()) {
90
90
  try {
@@ -93,7 +93,7 @@ export function reviveAll() {
93
93
  // whole canvas is the likeliest way to meet the cap, and every node in it
94
94
  // is one the daemon wants back: freeze the overflow and let the tick
95
95
  // bring them up as slots free.
96
- const outcome = reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' }).outcome;
96
+ const outcome = (await reviveNode(meta.node_id, { resume: true, recovery: true, capacity: 'freeze' })).outcome;
97
97
  if (outcome === 'frozen')
98
98
  result.frozen.push(meta.node_id);
99
99
  else
@@ -87,10 +87,10 @@ export type ReviveCapacityPolicy = 'freeze' | 'refuse';
87
87
  * Throws if the node does not exist. All other failures propagate as-is —
88
88
  * callers (daemon, command) decide how to handle.
89
89
  */
90
- export declare function reviveNode(nodeId: string, opts: ReviveOptions): ReviveResult;
90
+ export declare function reviveNode(nodeId: string, opts: ReviveOptions): Promise<ReviveResult>;
91
91
  /** The attach-specific entry to the revive boundary. A focus viewer may decline
92
92
  * to wake an exact parked resident while preserving the ordinary activating
93
93
  * attach default. The fleet check and parked decision are synchronous with the
94
94
  * launch below, so an already-running broker wins before dormancy is observed. */
95
- export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): AttachReviveDecision;
95
+ export declare function decideAttachRevive(nodeId: string, opts: ReviveOptions, wakeParked: boolean): Promise<AttachReviveDecision>;
96
96
  export {};