@north-light/crouter 0.3.282 → 0.3.284

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 (160) hide show
  1. package/dist/api/client.d.ts +22 -4
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/routes.d.ts +7 -0
  15. package/dist/api/routes.js +7 -0
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  18. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  19. package/dist/clients/attach/overlays/graph.js +65 -47
  20. package/dist/clients/attach/overlays/pickers.js +8 -9
  21. package/dist/clients/attach/render/card-presentation.js +1 -1
  22. package/dist/clients/attach/viewer.js +1286 -1260
  23. package/dist/commands/api-client.js +22 -23
  24. package/dist/commands/node/create.js +38 -3
  25. package/dist/commands/node/inspect.js +3 -7
  26. package/dist/commands/node/message.js +3 -3
  27. package/dist/commands/node/outcome.d.ts +2 -0
  28. package/dist/commands/node/outcome.js +134 -0
  29. package/dist/commands/node.js +2 -1
  30. package/dist/commands/profile/pause.js +17 -14
  31. package/dist/commands/push.js +102 -1
  32. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  33. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  34. package/dist/commands/sys/panels/models-panel.js +2 -2
  35. package/dist/commands/sys/settings-shell.d.ts +1 -1
  36. package/dist/commands/sys/settings-shell.js +6 -0
  37. package/dist/commands/sys/settings.js +3 -3
  38. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  40. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  41. package/dist/core/__tests__/helpers/harness.js +9 -0
  42. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  43. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  44. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  45. package/dist/core/__tests__/node-outcome.test.js +184 -0
  46. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  47. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  48. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  49. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  50. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  51. package/dist/core/bash-jobs.d.ts +6 -0
  52. package/dist/core/bash-jobs.js +22 -0
  53. package/dist/core/canvas/canvas.d.ts +41 -3
  54. package/dist/core/canvas/canvas.js +153 -39
  55. package/dist/core/canvas/crons.js +2 -1
  56. package/dist/core/canvas/index.d.ts +1 -0
  57. package/dist/core/canvas/index.js +1 -0
  58. package/dist/core/canvas/migrations.js +47 -0
  59. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  60. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  61. package/dist/core/canvas/pid.d.ts +7 -0
  62. package/dist/core/canvas/pid.js +17 -0
  63. package/dist/core/canvas/render-source.js +2 -2
  64. package/dist/core/canvas/types.d.ts +42 -1
  65. package/dist/core/canvas/types.js +2 -1
  66. package/dist/core/config.d.ts +4 -1
  67. package/dist/core/config.js +25 -1
  68. package/dist/core/feed/feed.d.ts +8 -0
  69. package/dist/core/feed/feed.js +25 -4
  70. package/dist/core/feed/inbox.d.ts +5 -0
  71. package/dist/core/feed/inbox.js +13 -0
  72. package/dist/core/keybindings/catalog.d.ts +1 -1
  73. package/dist/core/keybindings/catalog.js +3 -0
  74. package/dist/core/profiles/manifest.d.ts +3 -1
  75. package/dist/core/profiles/manifest.js +6 -1
  76. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  77. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  78. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  79. package/dist/core/runtime/broker/engine-drive.js +74 -27
  80. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  81. package/dist/core/runtime/broker/read-ops.js +14 -0
  82. package/dist/core/runtime/broker/rebind.js +0 -7
  83. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  84. package/dist/core/runtime/fleet.d.ts +7 -0
  85. package/dist/core/runtime/headless-pi.d.ts +13 -0
  86. package/dist/core/runtime/headless-pi.js +10 -2
  87. package/dist/core/runtime/lifecycle.d.ts +6 -1
  88. package/dist/core/runtime/lifecycle.js +50 -1
  89. package/dist/core/runtime/nodes.d.ts +5 -1
  90. package/dist/core/runtime/nodes.js +2 -1
  91. package/dist/core/runtime/outcome-document.d.ts +70 -0
  92. package/dist/core/runtime/outcome-document.js +97 -0
  93. package/dist/core/runtime/revive.js +16 -21
  94. package/dist/core/runtime/spawn.d.ts +5 -1
  95. package/dist/core/runtime/spawn.js +18 -7
  96. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  97. package/dist/core/runtime/stamp/channel.js +3 -10
  98. package/dist/core/runtime/stop-guard.js +2 -2
  99. package/dist/core/runtime/structured-output.d.ts +24 -3
  100. package/dist/core/runtime/structured-output.js +42 -3
  101. package/dist/core/runtime/tmux-bindings.js +4 -0
  102. package/dist/core/runtime/warm-pool.d.ts +5 -2
  103. package/dist/core/runtime/warm-pool.js +5 -5
  104. package/dist/core/subscription-state.js +7 -5
  105. package/dist/core/substrate/render.js +10 -10
  106. package/dist/core/user-settings.d.ts +18 -1
  107. package/dist/core/user-settings.js +37 -0
  108. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  109. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  110. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  111. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  112. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  113. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  114. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  115. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  116. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  117. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  118. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  119. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  120. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  121. package/dist/daemon/api/handlers/canvas.js +2 -2
  122. package/dist/daemon/api/handlers/messages.js +12 -2
  123. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  124. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  125. package/dist/daemon/api/handlers/nodes.js +67 -1
  126. package/dist/daemon/api/handlers/profiles.js +53 -1
  127. package/dist/daemon/api/handlers/reports.js +197 -33
  128. package/dist/daemon/api/map.d.ts +7 -2
  129. package/dist/daemon/api/map.js +78 -4
  130. package/dist/daemon/api/server.js +74 -7
  131. package/dist/daemon/crtrd.js +12 -0
  132. package/dist/daemon/fleet.d.ts +4 -8
  133. package/dist/daemon/fleet.js +48 -9
  134. package/dist/daemon/messaging/node-message.js +2 -1
  135. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  136. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  137. package/dist/daemon/park-activity.d.ts +9 -0
  138. package/dist/daemon/park-activity.js +30 -0
  139. package/dist/daemon/park-pending.d.ts +10 -5
  140. package/dist/daemon/park-pending.js +18 -8
  141. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  142. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  143. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  144. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  145. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  146. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  147. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  148. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  149. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  150. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  151. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  152. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  153. package/dist/shared/env.d.ts +8 -0
  154. package/dist/shared/env.js +14 -0
  155. package/dist/shared/generated-context.d.ts +3 -9
  156. package/dist/shared/generated-context.js +8 -16
  157. package/dist/types.d.ts +12 -0
  158. package/dist/types.js +1 -0
  159. package/package.json +9 -8
  160. package/runtime.lock.json +227 -383
@@ -0,0 +1,80 @@
1
+ import { type NodeOutcomeKind, type TerminalReason } from './types.js';
2
+ export type OutcomeDeliveryState = 'armed' | 'pending' | 'running' | 'accepted' | 'permanent_failed';
3
+ export interface OutcomeDeliveryFailure {
4
+ kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
5
+ exitCode?: number;
6
+ signal?: string;
7
+ message?: string;
8
+ stderr?: string;
9
+ }
10
+ /** Durable exponential retry delay for the attempt that just failed. */
11
+ export declare function outcomeDeliveryBackoffMs(attempt: number): number;
12
+ export interface OutcomeDeliveryRecord {
13
+ nodeId: string;
14
+ state: OutcomeDeliveryState;
15
+ attempt: number;
16
+ actionName: string;
17
+ argv: string[];
18
+ cwd: string;
19
+ configOrigin: string;
20
+ payload: unknown | null;
21
+ document: unknown | null;
22
+ nextAttemptAt: number;
23
+ claimedAt: number | null;
24
+ claimOwner: string | null;
25
+ acceptedAt: number | null;
26
+ permanentFailedAt: number | null;
27
+ lastFailure: OutcomeDeliveryFailure | null;
28
+ createdAt: number;
29
+ updatedAt: number;
30
+ }
31
+ export interface OutcomeDocumentSnapshot {
32
+ kind: NodeOutcomeKind;
33
+ reason: TerminalReason;
34
+ at: string;
35
+ revision: number;
36
+ detailJson: string | null;
37
+ }
38
+ /** Compose every delivery document from the hydrated identity and the immutable outcome latch. */
39
+ export declare function composeOutcomeDeliveryDocument(args: {
40
+ nodeId: string;
41
+ outcome: OutcomeDocumentSnapshot;
42
+ action: {
43
+ name: string;
44
+ payload: unknown;
45
+ };
46
+ finalReport?: string | null;
47
+ structuredResult?: unknown;
48
+ }): string;
49
+ /** Register a target atomically against the outcome latch. A still-armed target
50
+ * is replaceable; a settled registration is not. When the outcome already
51
+ * exists, the row is born pending with its complete immutable document. */
52
+ export declare function armOutcomeDelivery(row: {
53
+ nodeId: string;
54
+ actionName: string;
55
+ argv: string[];
56
+ cwd: string;
57
+ configOrigin: string;
58
+ payload?: unknown;
59
+ now?: number;
60
+ }): OutcomeDeliveryRecord | null;
61
+ export declare function readOutcomeDelivery(nodeId: string): OutcomeDeliveryRecord | null;
62
+ export declare function dueOutcomeDeliveries(now: number): OutcomeDeliveryRecord[];
63
+ export declare function claimOutcomeDelivery(nodeId: string, now: number, claimOwner: string): OutcomeDeliveryRecord | null;
64
+ export type OutcomeDeliverySettlement = {
65
+ state: 'accepted';
66
+ } | {
67
+ state: 'permanent_failed';
68
+ failure: OutcomeDeliveryFailure;
69
+ } | {
70
+ state: 'pending';
71
+ nextAttemptAt: number;
72
+ failure: OutcomeDeliveryFailure;
73
+ };
74
+ /** Settle only the still-owned running attempt, fencing stale delivery processes. */
75
+ export declare function settleOutcomeDelivery(nodeId: string, attempt: number, claimOwner: string, settlement: OutcomeDeliverySettlement, now: number): boolean;
76
+ export declare function recoverRunningOutcomeDeliveries(now: number): number;
77
+ /** Disarming is deliberately limited to registrations that have not started delivery. */
78
+ export declare function disarmOutcomeDelivery(nodeId: string): boolean;
79
+ /** A canonical final is the one allowed outcome supersede: discard any old claim and retry state. */
80
+ export declare function resetOutcomeDeliveryForSupersede(nodeId: string, documentJson: string | null, now: number): boolean;
@@ -0,0 +1,163 @@
1
+ // Durable delivery registration and attempt state for a node's terminal outcome.
2
+ import { openDb, withCanvasWrite } from './db.js';
3
+ import { getNode } from './canvas.js';
4
+ import { OUTCOME_PAYLOAD_MAX_BYTES } from './types.js';
5
+ import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
6
+ /** Durable exponential retry delay for the attempt that just failed. */
7
+ export function outcomeDeliveryBackoffMs(attempt) {
8
+ return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
9
+ }
10
+ function serializedPayload(payload) {
11
+ if (payload === undefined)
12
+ return null;
13
+ const json = JSON.stringify(payload);
14
+ if (json === undefined)
15
+ throw new TypeError('outcome delivery payload must be JSON-serializable');
16
+ if (Buffer.byteLength(json, 'utf8') > OUTCOME_PAYLOAD_MAX_BYTES) {
17
+ throw new RangeError(`outcome delivery payload exceeds ${OUTCOME_PAYLOAD_MAX_BYTES} bytes`);
18
+ }
19
+ return json;
20
+ }
21
+ function outcomeDeliveryFrom(row) {
22
+ return {
23
+ nodeId: row.node_id,
24
+ state: row.state,
25
+ attempt: row.attempt,
26
+ actionName: row.action_name,
27
+ argv: JSON.parse(row.argv_json),
28
+ cwd: row.cwd,
29
+ configOrigin: row.config_origin,
30
+ payload: row.payload_json === null ? null : JSON.parse(row.payload_json),
31
+ document: row.document_json === null ? null : JSON.parse(row.document_json),
32
+ nextAttemptAt: row.next_attempt_at,
33
+ claimedAt: row.claimed_at,
34
+ claimOwner: row.claim_owner,
35
+ acceptedAt: row.accepted_at,
36
+ permanentFailedAt: row.permanent_failed_at,
37
+ lastFailure: row.last_failure_json === null ? null : JSON.parse(row.last_failure_json),
38
+ createdAt: row.created_at,
39
+ updatedAt: row.updated_at,
40
+ };
41
+ }
42
+ /** The latch, rather than a later mutable row, defines the terminal view in a delivery document. */
43
+ function nodeAtOutcome(node, outcome) {
44
+ let status;
45
+ let intent;
46
+ if (outcome.kind === 'result' || outcome.reason === 'finished' || outcome.reason === 'declined') {
47
+ status = 'done';
48
+ intent = 'done';
49
+ }
50
+ else if (outcome.reason === 'parked') {
51
+ status = 'done';
52
+ intent = 'parked';
53
+ }
54
+ else if (outcome.reason === 'closed' || outcome.reason === 'retired' || outcome.reason === 'stranded' || outcome.reason === 'deadline_exceeded') {
55
+ status = 'canceled';
56
+ intent = null;
57
+ }
58
+ else {
59
+ status = 'dead';
60
+ intent = null;
61
+ }
62
+ return { ...node, status, intent, terminal_reason: outcome.reason };
63
+ }
64
+ /** Compose every delivery document from the hydrated identity and the immutable outcome latch. */
65
+ export function composeOutcomeDeliveryDocument(args) {
66
+ const node = getNode(args.nodeId);
67
+ if (node === null)
68
+ throw new Error(`unknown node ${args.nodeId}`);
69
+ return JSON.stringify(composeNodeOutcomeDocument({
70
+ node: nodeAtOutcome(node, args.outcome),
71
+ outcome: args.outcome,
72
+ action: args.action,
73
+ ...(args.finalReport === undefined ? {} : { finalReport: args.finalReport }),
74
+ ...(args.structuredResult === undefined ? {} : { structuredResult: args.structuredResult }),
75
+ }));
76
+ }
77
+ /** Register a target atomically against the outcome latch. A still-armed target
78
+ * is replaceable; a settled registration is not. When the outcome already
79
+ * exists, the row is born pending with its complete immutable document. */
80
+ export function armOutcomeDelivery(row) {
81
+ const now = row.now ?? Date.now();
82
+ const payloadJson = serializedPayload(row.payload);
83
+ return withCanvasWrite((db) => {
84
+ const node = db.prepare('SELECT * FROM nodes WHERE node_id = ?').get(row.nodeId);
85
+ if (node === undefined)
86
+ throw new Error(`unknown node ${row.nodeId}`);
87
+ const settled = node.outcome_at !== null;
88
+ if (settled && (node.outcome_kind === null || node.outcome_reason === null)) {
89
+ throw new Error(`outcome latch is incomplete for node ${row.nodeId}`);
90
+ }
91
+ const documentJson = !settled
92
+ ? null
93
+ : composeOutcomeDeliveryDocument({
94
+ nodeId: row.nodeId,
95
+ outcome: {
96
+ kind: node.outcome_kind,
97
+ reason: node.outcome_reason,
98
+ at: node.outcome_at,
99
+ revision: node.outcome_revision,
100
+ detailJson: node.outcome_detail_json,
101
+ },
102
+ action: { name: row.actionName, payload: row.payload ?? null },
103
+ finalReport: node.final_report,
104
+ });
105
+ const state = settled ? 'pending' : 'armed';
106
+ const inserted = db.prepare(`INSERT INTO node_outcome_deliveries
107
+ (node_id, state, action_name, argv_json, cwd, config_origin, payload_json, document_json, next_attempt_at, created_at, updated_at)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ ON CONFLICT(node_id) DO UPDATE SET
110
+ state = excluded.state, action_name = excluded.action_name, argv_json = excluded.argv_json,
111
+ cwd = excluded.cwd, config_origin = excluded.config_origin, payload_json = excluded.payload_json,
112
+ document_json = excluded.document_json, next_attempt_at = excluded.next_attempt_at,
113
+ updated_at = excluded.updated_at
114
+ WHERE node_outcome_deliveries.state = 'armed'`).run(row.nodeId, state, row.actionName, JSON.stringify(row.argv), row.cwd, row.configOrigin, payloadJson, documentJson, settled ? now : 0, now, now);
115
+ if (Number(inserted.changes) === 0)
116
+ return null;
117
+ return outcomeDeliveryFrom(db.prepare('SELECT * FROM node_outcome_deliveries WHERE node_id = ?').get(row.nodeId));
118
+ });
119
+ }
120
+ export function readOutcomeDelivery(nodeId) {
121
+ const row = openDb().prepare('SELECT * FROM node_outcome_deliveries WHERE node_id = ?').get(nodeId);
122
+ return row === undefined ? null : outcomeDeliveryFrom(row);
123
+ }
124
+ export function dueOutcomeDeliveries(now) {
125
+ return openDb().prepare(`SELECT * FROM node_outcome_deliveries
126
+ WHERE state = 'pending' AND next_attempt_at <= ?
127
+ ORDER BY next_attempt_at, created_at, node_id`).all(now).map(outcomeDeliveryFrom);
128
+ }
129
+ export function claimOutcomeDelivery(nodeId, now, claimOwner) {
130
+ return withCanvasWrite((db) => {
131
+ const claimed = db.prepare(`UPDATE node_outcome_deliveries
132
+ SET state = 'running', attempt = attempt + 1, claimed_at = ?, claim_owner = ?, updated_at = ?
133
+ WHERE node_id = ? AND state = 'pending' AND next_attempt_at <= ?`).run(now, claimOwner, now, nodeId, now);
134
+ if (Number(claimed.changes) === 0)
135
+ return null;
136
+ return outcomeDeliveryFrom(db.prepare('SELECT * FROM node_outcome_deliveries WHERE node_id = ?').get(nodeId));
137
+ });
138
+ }
139
+ /** Settle only the still-owned running attempt, fencing stale delivery processes. */
140
+ export function settleOutcomeDelivery(nodeId, attempt, claimOwner, settlement, now) {
141
+ const failure = settlement.state === 'accepted' ? null : JSON.stringify(settlement.failure);
142
+ return withCanvasWrite((db) => Number(db.prepare(`UPDATE node_outcome_deliveries
143
+ SET state = ?, next_attempt_at = ?, claimed_at = NULL, claim_owner = NULL,
144
+ accepted_at = ?, permanent_failed_at = ?, last_failure_json = ?, updated_at = ?
145
+ WHERE node_id = ? AND state = 'running' AND attempt = ? AND claim_owner = ?`).run(settlement.state, settlement.state === 'pending' ? settlement.nextAttemptAt : now, settlement.state === 'accepted' ? now : null, settlement.state === 'permanent_failed' ? now : null, failure, now, nodeId, attempt, claimOwner).changes) === 1);
146
+ }
147
+ export function recoverRunningOutcomeDeliveries(now) {
148
+ return withCanvasWrite((db) => Number(db.prepare(`UPDATE node_outcome_deliveries
149
+ SET state = 'pending', next_attempt_at = ?, claimed_at = NULL, claim_owner = NULL, updated_at = ?
150
+ WHERE state = 'running'`).run(now, now).changes));
151
+ }
152
+ /** Disarming is deliberately limited to registrations that have not started delivery. */
153
+ export function disarmOutcomeDelivery(nodeId) {
154
+ return withCanvasWrite((db) => Number(db.prepare("DELETE FROM node_outcome_deliveries WHERE node_id = ? AND state = 'armed'").run(nodeId).changes) === 1);
155
+ }
156
+ /** A canonical final is the one allowed outcome supersede: discard any old claim and retry state. */
157
+ export function resetOutcomeDeliveryForSupersede(nodeId, documentJson, now) {
158
+ return withCanvasWrite((db) => Number(db.prepare(`UPDATE node_outcome_deliveries
159
+ SET state = 'pending', attempt = 0, document_json = ?, next_attempt_at = ?,
160
+ claimed_at = NULL, claim_owner = NULL, accepted_at = NULL, permanent_failed_at = NULL,
161
+ last_failure_json = NULL, updated_at = ?
162
+ WHERE node_id = ?`).run(documentJson, now, now, nodeId).changes) === 1);
163
+ }
@@ -4,6 +4,13 @@
4
4
  * zombies and processes reaped between the two probes. Only an unavailable
5
5
  * `ps` probe fails open to alive. A null/undefined pid reads dead. */
6
6
  export declare function isPidAlive(pid: number | null | undefined): boolean;
7
+ /** Existence-only liveness for READ-ONLY display projections: the signal-0
8
+ * probe alone, no `ps`. A zombie reads alive here, which a dashboard column
9
+ * tolerates and which no lifecycle decision may rely on — revive, teardown,
10
+ * and every other authority stays on `isPidAlive`. Exists because the
11
+ * dashboard snapshot runs this per live row per request; a `ps` spawn each
12
+ * time was a third of daemon CPU on a large canvas. */
13
+ export declare function isPidPresent(pid: number | null | undefined): boolean;
7
14
  /** THE single precision-aware identity compare, used EVERYWHERE two
8
15
  * `composeIdentity` fingerprints are compared (this module's own
9
16
  * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and
@@ -46,6 +46,23 @@ export function isPidAlive(pid) {
46
46
  const state = psProcessState(pid);
47
47
  return state !== 'zombie' && state !== 'gone';
48
48
  }
49
+ /** Existence-only liveness for READ-ONLY display projections: the signal-0
50
+ * probe alone, no `ps`. A zombie reads alive here, which a dashboard column
51
+ * tolerates and which no lifecycle decision may rely on — revive, teardown,
52
+ * and every other authority stays on `isPidAlive`. Exists because the
53
+ * dashboard snapshot runs this per live row per request; a `ps` spawn each
54
+ * time was a third of daemon CPU on a large canvas. */
55
+ export function isPidPresent(pid) {
56
+ if (pid == null)
57
+ return false;
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ }
62
+ catch (e) {
63
+ return e.code === 'EPERM';
64
+ }
65
+ }
49
66
  /** Matches the kernel `boot_id` UUID shape (`/proc/sys/kernel/random/boot_id`),
50
67
  * used to tell a NEW-format identity base (a per-boot UUID) apart from a
51
68
  * LEGACY one (a `ps lstart` wall-clock timestamp). A boot_id is a lowercase
@@ -19,7 +19,7 @@ import { fullName } from './labels.js';
19
19
  import { jobDir, contextDir, reportsDir } from './paths.js';
20
20
  import { readNodeRecap } from './node-recap.js';
21
21
  import { parseFrontmatterGeneric } from '../frontmatter.js';
22
- import { isPidAlive } from './pid.js';
22
+ import { isPidAlive, isPidPresent } from './pid.js';
23
23
  import { isFrozenRow, resolveNodeVisual, faultSummary } from './status-glyph.js';
24
24
  import { readFault } from '../runtime/fault.js';
25
25
  import { parseCard } from '../../shared/generated-context.js';
@@ -434,7 +434,7 @@ function assistantContentIsSubstantive(content) {
434
434
  * (an active node is usually dormant between turns). Read directly off disk
435
435
  * (mirrors telemetry) to avoid inverting the canvas→runtime dependency. */
436
436
  export function isStreaming(nodeId, piPid) {
437
- if (!isPidAlive(piPid))
437
+ if (!isPidPresent(piPid))
438
438
  return false;
439
439
  try {
440
440
  return existsSync(join(jobDir(nodeId), 'busy'));
@@ -18,7 +18,34 @@ export type Lifecycle = 'terminal' | 'resident';
18
18
  export type Mode = 'base' | 'orchestrator';
19
19
  /** Why a node last stopped — drives the daemon's reap-vs-revive decision. */
20
20
  export type ExitIntent = 'done' | 'refresh' | 'idle-release' | 'parked' | null;
21
- export type TerminalReason = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded';
21
+ export type TerminalReason = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded' | 'boot_failed' | 'crash_looped' | 'launch_failed' | 'context_overflow' | 'deadline_exceeded' | 'provider_fatal' | 'declined';
22
+ export type NodeOutcomeKind = 'result' | 'failure';
23
+ /** Maximum serialized opaque action payload; enforced at registration in Phase 5. */
24
+ export declare const OUTCOME_PAYLOAD_MAX_BYTES: number;
25
+ export interface NodeOutcomeDetailV1 {
26
+ schema: 'crtr.node-outcome-detail/v1';
27
+ message?: string;
28
+ fault_kind?: import('../fault-classifier.js').FaultKind;
29
+ error_class?: import('../events/types.js').ErrorClassCode;
30
+ retry?: {
31
+ attempt: number;
32
+ max: number;
33
+ };
34
+ respawn_failures?: number;
35
+ deadline?: {
36
+ deadline_at: string;
37
+ elapsed_ms: number;
38
+ };
39
+ /** Present only for reason `declined`: what the node gave `crtr push result --decline`. */
40
+ declined?: DeclinedResult;
41
+ truncated?: true;
42
+ }
43
+ /** A declined structured result. `code` is opaque here — the requester validates it. */
44
+ export interface DeclinedResult {
45
+ reason: string;
46
+ code: string;
47
+ retryable: boolean;
48
+ }
22
49
  /** The two structural edges. `subscribes_to` is the load-bearing spine (flow,
23
50
  * org chart, views, completion routing). `spawned_by` is audit only. */
24
51
  export type EdgeType = 'subscribes_to' | 'spawned_by';
@@ -266,6 +293,14 @@ export interface NodeRuntime {
266
293
  final_report?: string | null;
267
294
  /** ISO timestamp carried by the committed canonical final report, or null. */
268
295
  finalized_at?: string | null;
296
+ /** Durable terminal outcome. `outcome_at` is its write-once latch. */
297
+ outcome_kind?: NodeOutcomeKind | null;
298
+ outcome_reason?: TerminalReason | null;
299
+ outcome_at?: string | null;
300
+ outcome_revision?: number;
301
+ outcome_detail_json?: string | null;
302
+ /** Absolute wall-clock spawn deadline, or null when unbounded. */
303
+ deadline_at?: string | null;
269
304
  /** OS pid of the live broker process, recorded on boot (stophook session_start).
270
305
  * The daemon's authoritative liveness signal — every node is a detached broker,
271
306
  * so `isPidAlive(pi_pid)` is the sole liveness check. Cleared to null by
@@ -342,6 +377,12 @@ export interface NodeRow {
342
377
  terminal_reason?: TerminalReason | null;
343
378
  final_report?: string | null;
344
379
  finalized_at?: string | null;
380
+ outcome_kind?: NodeOutcomeKind | null;
381
+ outcome_reason?: TerminalReason | null;
382
+ outcome_at?: string | null;
383
+ outcome_revision?: number;
384
+ outcome_detail_json?: string | null;
385
+ deadline_at?: string | null;
345
386
  pi_pid: number | null;
346
387
  /** See `NodeRuntime.pi_pid_identity`. */
347
388
  pi_pid_identity: string | null;
@@ -5,4 +5,5 @@
5
5
  // `meta.json` is the source of truth for its own row; the db is a queryable
6
6
  // index over those metas, plus the authoritative store for the mutable
7
7
  // `subscribes_to` edges (which no single meta owns).
8
- export {};
8
+ /** Maximum serialized opaque action payload; enforced at registration in Phase 5. */
9
+ export const OUTCOME_PAYLOAD_MAX_BYTES = 64 * 1024;
@@ -1,4 +1,4 @@
1
- import type { PageComponentRegistration, Scope, ScopeConfig, ScopeState, HumanActionConfig, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
1
+ import type { PageComponentRegistration, Scope, ScopeConfig, ScopeState, HumanActionConfig, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig, ProviderOptionsConfig } from '../types.js';
2
2
  import { type BindingId } from './keybindings/catalog.js';
3
3
  export declare function configPath(scope: Scope): string | null;
4
4
  export declare function statePath(scope: Scope): string | null;
@@ -111,6 +111,9 @@ export declare function readRawScopeConfig(scope: Scope): Partial<ScopeConfig> |
111
111
  export interface MergedLaunchConfig {
112
112
  kinds: Record<string, KindConfig>;
113
113
  modelLadders: ModelLaddersConfig;
114
+ /** Per-provider behavior options keyed by runtime provider id, merged
115
+ * per provider across scopes (project stack > profile > user). */
116
+ providerOptions: Record<string, ProviderOptionsConfig>;
114
117
  /** Complete declared routes. Every route here has all four strengths. */
115
118
  modelRoutes?: Record<string, ModelRouteConfig>;
116
119
  /** Read-only compatibility view for sparse routes written before complete
@@ -343,6 +343,25 @@ function mergeSpawnEnv(raw, base = []) {
343
343
  }
344
344
  return { allow: [...new Set([...base, ...extra])] };
345
345
  }
346
+ /** Per-provider options, merged SHALLOWLY per provider id so a nearer scope
347
+ * that sets one option does not erase a sibling option from a farther scope. */
348
+ function mergeProviderOptions(raw, base = {}) {
349
+ const merged = {};
350
+ for (const [providerId, options] of Object.entries(base))
351
+ merged[providerId] = { ...options };
352
+ if (raw !== null && typeof raw === 'object') {
353
+ for (const [providerId, options] of Object.entries(raw)) {
354
+ if (options === null || typeof options !== 'object')
355
+ continue;
356
+ const next = { ...merged[providerId] };
357
+ const fastMode = options.fastMode;
358
+ if (typeof fastMode === 'boolean')
359
+ next.fastMode = fastMode;
360
+ merged[providerId] = next;
361
+ }
362
+ }
363
+ return merged;
364
+ }
346
365
  /** The one bare-command-name rule, shared by the read path (`normalizeBin`)
347
366
  * and the strict install gate (`invalidPluginBinReasons`): a name a shell can
348
367
  * invoke verbatim and a filesystem can hold as a single symlink entry — no
@@ -761,6 +780,7 @@ function mergeConfig(partial) {
761
780
  const keybindings = normalizeKeybindings(partial.keybindings);
762
781
  const tmux_passthrough = normalizeTmuxPassthrough(partial.tmux_passthrough);
763
782
  const modelLadders = mergeModelLadders(partial.modelLadders);
783
+ const providerOptions = mergeProviderOptions(partial.providerOptions, defaults.providerOptions);
764
784
  const routes = resolveModelRoutes(mergeModelRoutePatches(partial.modelRoutes));
765
785
  const modelRoutes = routes.complete;
766
786
  const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
@@ -769,7 +789,7 @@ function mergeConfig(partial) {
769
789
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
770
790
  const bin = normalizeBin(partial.bin);
771
791
  const humanActions = normalizeHumanActions(partial.humanActions);
772
- return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
792
+ return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, providerOptions, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
773
793
  }
774
794
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
775
795
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -858,12 +878,14 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
858
878
  // (ladders/routes/spawnEnv) have no plugin channel.
859
879
  let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
860
880
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
881
+ let providerOptions = mergeProviderOptions(userRaw?.providerOptions, defaults.providerOptions);
861
882
  let modelRoutePatches = mergeModelRoutePatches(userRaw?.modelRoutes);
862
883
  let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
863
884
  let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
864
885
  if (profileRaw !== null) {
865
886
  kinds = mergeKinds(profileRaw.kinds, kinds);
866
887
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
888
+ providerOptions = mergeProviderOptions(profileRaw.providerOptions, providerOptions);
867
889
  modelRoutePatches = mergeModelRoutePatches(profileRaw.modelRoutes, modelRoutePatches);
868
890
  modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
869
891
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
@@ -873,6 +895,7 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
873
895
  if (raw === null)
874
896
  continue;
875
897
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
898
+ providerOptions = mergeProviderOptions(raw.providerOptions, providerOptions);
876
899
  modelRoutePatches = mergeModelRoutePatches(raw.modelRoutes, modelRoutePatches);
877
900
  modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
878
901
  spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
@@ -881,6 +904,7 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
881
904
  return {
882
905
  kinds,
883
906
  modelLadders,
907
+ providerOptions,
884
908
  ...(Object.keys(routes.complete).length > 0 ? { modelRoutes: routes.complete } : {}),
885
909
  ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}),
886
910
  ...(modelRouting !== undefined ? { modelRouting } : {}),
@@ -9,6 +9,14 @@ export interface PushOpts {
9
9
  from?: string;
10
10
  /** Attribution snapshotted by the human-ticket settlement paths; absent means an ordinary report. */
11
11
  disposition?: InboxDisposition;
12
+ /** Final only. The outcome the canonical final records; absent means `result`/`finalized`. */
13
+ outcome?: FinalOutcome;
14
+ }
15
+ /** A declined structured result: the node finishes, but its latch records a failure. */
16
+ export interface FinalOutcome {
17
+ kind: 'failure';
18
+ reason: 'declined';
19
+ detailJson: string | null;
12
20
  }
13
21
  export interface PushResult {
14
22
  reportPath: string;
@@ -2,7 +2,7 @@
2
2
  import { existsSync, linkSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { openDb, reportsDir, subscribersOf, cancelCronsOnWake, getNode } from '../canvas/index.js';
5
+ import { openDb, reportsDir, subscribersOf, cancelCronsOnWake, composeOutcomeDeliveryDocument, getNode, readOutcomeDelivery, resetOutcomeDeliveryForSupersede } from '../canvas/index.js';
6
6
  import { appendInbox } from './inbox.js';
7
7
  import { appendPassive } from './passive.js';
8
8
  import { operationIdContext } from '../events/operation-id.js';
@@ -77,7 +77,7 @@ function deliver(subscribers, from, fromName, kind, requestedTier, reportRef, bo
77
77
  return { deliveredTo, failedTo };
78
78
  }
79
79
  /** Claim the one canonical final while holding SQLite's write lock. */
80
- function commitCanonicalFinal(nodeId, staged) {
80
+ function commitCanonicalFinal(nodeId, staged, outcome) {
81
81
  const db = openDb();
82
82
  let installed = false;
83
83
  db.exec('BEGIN IMMEDIATE');
@@ -92,9 +92,28 @@ function commitCanonicalFinal(nodeId, staged) {
92
92
  if (row.status !== 'active' && row.status !== 'idle' && row.status !== 'done')
93
93
  throw new FinalizationError('finalization_forbidden');
94
94
  const subscribers = db.prepare(`SELECT from_id AS node_id, active, created FROM edges WHERE type = 'subscribes_to' AND to_id = ? ORDER BY created`).all(nodeId).map((row) => ({ node_id: row['node_id'], active: row['active'] === 1, created: row['created'] }));
95
+ const delivery = readOutcomeDelivery(nodeId);
96
+ const node = getNode(nodeId);
97
+ const recordsOutcome = node?.kind !== 'human';
98
+ const revision = (node?.outcome_revision ?? 0) + 1;
99
+ const outcomeKind = outcome?.kind ?? 'result';
100
+ const outcomeReason = outcome?.reason ?? 'finalized';
101
+ const outcomeDetailJson = outcome?.detailJson ?? null;
102
+ const documentJson = !recordsOutcome || delivery === null || node === null ? null : composeOutcomeDeliveryDocument({
103
+ nodeId,
104
+ outcome: { kind: outcomeKind, reason: outcomeReason, at: staged.ts, revision, detailJson: outcomeDetailJson },
105
+ action: { name: delivery.actionName, payload: delivery.payload },
106
+ finalReport: staged.basename,
107
+ });
95
108
  install(staged);
96
109
  installed = true;
97
- db.prepare("UPDATE nodes SET status = 'done', intent = 'done', terminal_reason = 'finalized', final_report = ?, finalized_at = ? WHERE node_id = ? AND final_report IS NULL").run(staged.basename, staged.ts, nodeId);
110
+ if (recordsOutcome) {
111
+ db.prepare("UPDATE nodes SET status = 'done', intent = 'done', terminal_reason = ?, final_report = ?, finalized_at = ?, outcome_kind = ?, outcome_reason = ?, outcome_at = ?, outcome_revision = outcome_revision + 1, outcome_detail_json = ? WHERE node_id = ? AND final_report IS NULL").run(outcomeReason, staged.basename, staged.ts, outcomeKind, outcomeReason, staged.ts, outcomeDetailJson, nodeId);
112
+ resetOutcomeDeliveryForSupersede(nodeId, documentJson, Date.now());
113
+ }
114
+ else {
115
+ db.prepare("UPDATE nodes SET status = 'done', intent = 'done', terminal_reason = 'finalized', final_report = ?, finalized_at = ? WHERE node_id = ? AND final_report IS NULL").run(staged.basename, staged.ts, nodeId);
116
+ }
98
117
  db.exec('COMMIT');
99
118
  return { subscribers };
100
119
  }
@@ -113,11 +132,13 @@ async function pushWithContext(nodeId, opts) {
113
132
  throw new Error(`push: empty nodeId (kind=${opts.kind}) — a report must belong to a node`);
114
133
  if (opts.kind === 'final' && opts.deliveryTier !== undefined)
115
134
  throw new Error('push final does not accept a delivery tier');
135
+ if (opts.kind !== 'final' && opts.outcome !== undefined)
136
+ throw new Error('push outcome is final-only');
116
137
  const staged = stageReport(nodeId, opts.kind, opts.body);
117
138
  const from = opts.from ?? nodeId;
118
139
  const fromName = getNode(from)?.name;
119
140
  if (opts.kind === 'final') {
120
- const { subscribers } = commitCanonicalFinal(nodeId, staged);
141
+ const { subscribers } = commitCanonicalFinal(nodeId, staged, opts.outcome);
121
142
  // The finalization latch ends every wait this node was holding. It flips the
122
143
  // row to done DIRECTLY (not through lifecycle's `finish` event), so the
123
144
  // deadline cleanup must ride here too: an armed deadline outliving the final
@@ -87,6 +87,11 @@ export declare function writeCursor(nodeId: string, entryId: string): void;
87
87
  * (do not park, do not shut down, do not resume quietly), so report it and
88
88
  * return that. */
89
89
  export declare function hasUnseenWakeMail(nodeId: string): boolean;
90
+ /** Whether wake-capable mail arrived at or after a parking turn began. Unlike
91
+ * `hasUnseenWakeMail`, this deliberately includes mail the main engine already
92
+ * consumed: a shadow completion must not undo activity that occurred while it
93
+ * ran. A failed scan conservatively keeps the node live. */
94
+ export declare function hasWakeMailSince(nodeId: string, sinceMs: number): boolean;
90
95
  /** Clip a body to a bounded preview, reporting whether anything was dropped. */
91
96
  export declare function clipBody(body: string): {
92
97
  text: string;
@@ -315,6 +315,19 @@ export function hasUnseenWakeMail(nodeId) {
315
315
  return true;
316
316
  }
317
317
  }
318
+ /** Whether wake-capable mail arrived at or after a parking turn began. Unlike
319
+ * `hasUnseenWakeMail`, this deliberately includes mail the main engine already
320
+ * consumed: a shadow completion must not undo activity that occurred while it
321
+ * ran. A failed scan conservatively keeps the node live. */
322
+ export function hasWakeMailSince(nodeId, sinceMs) {
323
+ try {
324
+ return readPhysicalInbox(nodeId).some((entry) => entry.tier !== 'deferred' && Date.parse(entry.ts) >= sinceMs);
325
+ }
326
+ catch (error) {
327
+ emitEvent({ level: 'warn', event: 'inbox.wake_scan.failed', node_id: nodeId, error });
328
+ return true;
329
+ }
330
+ }
318
331
  // Coalesce
319
332
  /** Bounds for inlining a message body's preview in the digest. */
320
333
  const BODY_MAX_LINES = 12;
@@ -1,6 +1,6 @@
1
1
  import type { BindingDefinition, SafetyRequirement } from './types.js';
2
2
  import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
3
- type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'context-admin' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
3
+ type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'provider-options' | 'fold-tools' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'context-admin' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
4
4
  type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
5
5
  type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
6
6
  type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'report' | 'pin' | 'search' | 'revive-now' | 'focus' | 'focus-background' | 'yank-id';
@@ -55,6 +55,9 @@ export const BINDING_CATALOG = Object.freeze([
55
55
  ...menuEntry('metadata', 'Show metadata', 'Show the focused node’s metadata dossier.', ['m'], ['tmux.menu.show']),
56
56
  ...menuEntry('context', 'Browse context', 'Browse the focused node context.', ['c'], ['tmux.menu.show']),
57
57
  ...menuEntry('providers', 'Open settings', 'Open the crouter settings panel.', [','], ['tmux.menu']),
58
+ // Next to the settings comma: the same page, opened on its provider-options
59
+ // destination.
60
+ ...menuEntry('provider-options', 'Provider options', 'Open provider options — how an authenticated provider serves your turns, including Codex fast mode.', ['.'], ['tmux.menu']),
58
61
  // j and k steer the menu cursor, so no selector may claim either.
59
62
  ...menuEntry('plugins', 'Browse plugins', 'Open the crouter plugin browser.', ['l'], ['tmux.menu']),
60
63
  // Lowercase c opens the create submenu, so the context surface takes the
@@ -114,7 +114,9 @@ export declare function isProfilePaused(profileId: string | null | undefined): b
114
114
  export declare function assertProfileEntryActive(entry: ProfileEntry): void;
115
115
  export declare function assertProfileActive(profileId: string | null | undefined): ProfileEntry | null;
116
116
  export declare function pauseProfile(profileId: string): ProfileEntry;
117
- export declare function resumeProfile(profileId: string): ProfileEntry;
117
+ export declare function resumeProfile(profileId: string): ProfileEntry & {
118
+ pausedAt: string | null;
119
+ };
118
120
  export declare function setProfileDefaultKind(profileId: string, defaultKind: string): ProfileEntry;
119
121
  /** Merge `set` entries over the stored map and drop `unset` keys; the
120
122
  * `metadata` field is omitted entirely when the result is empty. */
@@ -592,7 +592,12 @@ export function pauseProfile(profileId) {
592
592
  return mutateManifest(profileId, (m) => ({ ...m, paused_at: m.paused_at ?? nowIso() }));
593
593
  }
594
594
  export function resumeProfile(profileId) {
595
- return mutateManifest(profileId, (m) => ({ ...m, paused_at: null }));
595
+ let pausedAt = null;
596
+ const entry = mutateManifest(profileId, (m) => {
597
+ pausedAt = m.paused_at;
598
+ return { ...m, paused_at: null };
599
+ });
600
+ return { ...entry, pausedAt };
596
601
  }
597
602
  export function setProfileDefaultKind(profileId, defaultKind) {
598
603
  return mutateManifest(profileId, (m) => ({ ...m, default_kind: defaultKind }));
@@ -1,8 +1,10 @@
1
- import type { BrokerExtensionStateDTO, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerInboxCursorDirective, BrokerInboxCursorRequest, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest } from '../../../api/dto/broker-ops.js';
1
+ import type { BrokerExtensionStateDTO, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerInboxCursorDirective, BrokerInboxCursorRequest, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest } from '../../../api/dto/broker-ops.js';
2
2
  import type { RelaunchRootResultDTO } from '../../../api/dto/lifecycle.js';
3
3
  import type { PushReportRequest } from '../../../api/dto/reports.js';
4
4
  export declare function bindBrokerSession(nodeId: string, request: BrokerSessionBoundRequest): Promise<BrokerSessionBoundResultDTO>;
5
5
  export declare function settleBroker(nodeId: string, request: BrokerSettleRequest): Promise<BrokerSettleDirective>;
6
+ export declare function completeBrokerPark(nodeId: string, request: BrokerParkCompleteRequest): Promise<BrokerSettleDirective>;
7
+ export declare function recordBrokerParkActivity(nodeId: string): Promise<BrokerParkActivityResultDTO>;
6
8
  export declare function advanceBrokerInboxCursor(nodeId: string, request: BrokerInboxCursorRequest): Promise<BrokerInboxCursorDirective>;
7
9
  /** Fixed canvas projection for broker extension rendering. It deliberately
8
10
  * exposes no generic node/database facade. */