@north-light/api 0.0.0 → 0.3.415

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 (251) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +66 -2
  3. package/dist/api/__tests__/deadline.test.d.ts +1 -0
  4. package/dist/api/__tests__/deadline.test.js +46 -0
  5. package/dist/api/__tests__/error-codes.test.d.ts +1 -0
  6. package/dist/api/__tests__/error-codes.test.js +79 -0
  7. package/dist/api/__tests__/integration/client.test.d.ts +1 -0
  8. package/dist/api/__tests__/integration/client.test.js +276 -0
  9. package/dist/api/client.d.ts +521 -0
  10. package/dist/api/client.js +1388 -0
  11. package/dist/api/command-manifest/index.d.ts +3 -0
  12. package/dist/api/command-manifest/index.js +3 -0
  13. package/dist/api/command-manifest/manifest.d.ts +51 -0
  14. package/dist/api/command-manifest/manifest.js +332 -0
  15. package/dist/api/command-manifest/result.d.ts +25 -0
  16. package/dist/api/command-manifest/result.js +97 -0
  17. package/dist/api/command-manifest/schema.d.ts +28 -0
  18. package/dist/api/command-manifest/schema.js +856 -0
  19. package/dist/api/dto/analytics.d.ts +184 -0
  20. package/dist/api/dto/analytics.js +3 -0
  21. package/dist/api/dto/attach.d.ts +22 -0
  22. package/dist/api/dto/attach.js +13 -0
  23. package/dist/api/dto/bash-jobs.d.ts +24 -0
  24. package/dist/api/dto/bash-jobs.js +9 -0
  25. package/dist/api/dto/bash.d.ts +17 -0
  26. package/dist/api/dto/bash.js +1 -0
  27. package/dist/api/dto/broker-ops.d.ts +187 -0
  28. package/dist/api/dto/broker-ops.js +6 -0
  29. package/dist/api/dto/broker-signals.d.ts +25 -0
  30. package/dist/api/dto/broker-signals.js +1 -0
  31. package/dist/api/dto/broker.d.ts +86 -0
  32. package/dist/api/dto/broker.js +20 -0
  33. package/dist/api/dto/canvas.d.ts +359 -0
  34. package/dist/api/dto/canvas.js +2 -0
  35. package/dist/api/dto/chat-inventory.d.ts +56 -0
  36. package/dist/api/dto/chat-inventory.js +11 -0
  37. package/dist/api/dto/common.d.ts +29 -0
  38. package/dist/api/dto/common.js +15 -0
  39. package/dist/api/dto/config.d.ts +46 -0
  40. package/dist/api/dto/config.js +3 -0
  41. package/dist/api/dto/crons.d.ts +164 -0
  42. package/dist/api/dto/crons.js +10 -0
  43. package/dist/api/dto/custom-objects.d.ts +66 -0
  44. package/dist/api/dto/custom-objects.js +1 -0
  45. package/dist/api/dto/delivery.d.ts +71 -0
  46. package/dist/api/dto/delivery.js +7 -0
  47. package/dist/api/dto/docs.d.ts +135 -0
  48. package/dist/api/dto/docs.js +8 -0
  49. package/dist/api/dto/files.d.ts +21 -0
  50. package/dist/api/dto/files.js +1 -0
  51. package/dist/api/dto/focus.d.ts +24 -0
  52. package/dist/api/dto/focus.js +10 -0
  53. package/dist/api/dto/grants.d.ts +28 -0
  54. package/dist/api/dto/grants.js +1 -0
  55. package/dist/api/dto/health.d.ts +116 -0
  56. package/dist/api/dto/health.js +2 -0
  57. package/dist/api/dto/human-requests.d.ts +113 -0
  58. package/dist/api/dto/human-requests.js +4 -0
  59. package/dist/api/dto/human.d.ts +28 -0
  60. package/dist/api/dto/human.js +4 -0
  61. package/dist/api/dto/inbox.d.ts +273 -0
  62. package/dist/api/dto/inbox.js +4 -0
  63. package/dist/api/dto/lifecycle.d.ts +88 -0
  64. package/dist/api/dto/lifecycle.js +3 -0
  65. package/dist/api/dto/limits.d.ts +29 -0
  66. package/dist/api/dto/limits.js +2 -0
  67. package/dist/api/dto/mail.d.ts +44 -0
  68. package/dist/api/dto/mail.js +1 -0
  69. package/dist/api/dto/messages.d.ts +104 -0
  70. package/dist/api/dto/messages.js +2 -0
  71. package/dist/api/dto/model-config.d.ts +25 -0
  72. package/dist/api/dto/model-config.js +1 -0
  73. package/dist/api/dto/modelauth.d.ts +132 -0
  74. package/dist/api/dto/modelauth.js +4 -0
  75. package/dist/api/dto/node-events.d.ts +65 -0
  76. package/dist/api/dto/node-events.js +4 -0
  77. package/dist/api/dto/node-outcomes.d.ts +88 -0
  78. package/dist/api/dto/node-outcomes.js +2 -0
  79. package/dist/api/dto/node-records.d.ts +35 -0
  80. package/dist/api/dto/node-records.js +5 -0
  81. package/dist/api/dto/nodes.d.ts +371 -0
  82. package/dist/api/dto/nodes.js +3 -0
  83. package/dist/api/dto/objects.d.ts +172 -0
  84. package/dist/api/dto/objects.js +5 -0
  85. package/dist/api/dto/profiles.d.ts +117 -0
  86. package/dist/api/dto/profiles.js +4 -0
  87. package/dist/api/dto/recovery.d.ts +104 -0
  88. package/dist/api/dto/recovery.js +1 -0
  89. package/dist/api/dto/reports.d.ts +95 -0
  90. package/dist/api/dto/reports.js +2 -0
  91. package/dist/api/dto/review-comments.d.ts +146 -0
  92. package/dist/api/dto/review-comments.js +5 -0
  93. package/dist/api/dto/reviews.d.ts +113 -0
  94. package/dist/api/dto/reviews.js +5 -0
  95. package/dist/api/dto/run-events.d.ts +295 -0
  96. package/dist/api/dto/run-events.js +6 -0
  97. package/dist/api/dto/structured-requests.d.ts +64 -0
  98. package/dist/api/dto/structured-requests.js +5 -0
  99. package/dist/api/dto/subscriptions.d.ts +14 -0
  100. package/dist/api/dto/subscriptions.js +2 -0
  101. package/dist/api/dto/usage.d.ts +27 -0
  102. package/dist/api/dto/usage.js +1 -0
  103. package/dist/api/dto/worktree.d.ts +55 -0
  104. package/dist/api/dto/worktree.js +6 -0
  105. package/dist/api/error-codes.d.ts +259 -0
  106. package/dist/api/error-codes.js +56 -0
  107. package/dist/api/errors.d.ts +47 -0
  108. package/dist/api/errors.js +66 -0
  109. package/dist/api/index.d.ts +49 -0
  110. package/dist/api/index.js +46 -0
  111. package/dist/api/long-poll.d.ts +59 -0
  112. package/dist/api/long-poll.js +67 -0
  113. package/dist/api/node-transport.d.ts +18 -0
  114. package/dist/api/node-transport.js +105 -0
  115. package/dist/api/plugin-manifest-schema.d.ts +233 -0
  116. package/dist/api/plugin-manifest-schema.js +23 -0
  117. package/dist/api/routes.d.ts +164 -0
  118. package/dist/api/routes.js +197 -0
  119. package/dist/api/runtime-version.d.ts +31 -0
  120. package/dist/api/runtime-version.js +50 -0
  121. package/dist/core/asset-root.d.ts +7 -0
  122. package/dist/core/asset-root.js +18 -0
  123. package/dist/core/canvas/boot-id.d.ts +6 -0
  124. package/dist/core/canvas/boot-id.js +26 -0
  125. package/dist/core/canvas/paths.d.ts +72 -0
  126. package/dist/core/canvas/paths.js +163 -0
  127. package/dist/core/canvas/pid.d.ts +391 -0
  128. package/dist/core/canvas/pid.js +948 -0
  129. package/dist/core/command-plugins/bundle.d.ts +149 -0
  130. package/dist/core/command-plugins/bundle.js +588 -0
  131. package/dist/core/command-plugins/endpoint.d.ts +24 -0
  132. package/dist/core/command-plugins/endpoint.js +51 -0
  133. package/dist/core/config.d.ts +233 -0
  134. package/dist/core/config.js +1120 -0
  135. package/dist/core/env-name.d.ts +6 -0
  136. package/dist/core/env-name.js +9 -0
  137. package/dist/core/errors.d.ts +38 -0
  138. package/dist/core/errors.js +90 -0
  139. package/dist/core/events/emit.d.ts +6 -0
  140. package/dist/core/events/emit.js +42 -0
  141. package/dist/core/events/envelope.d.ts +2 -0
  142. package/dist/core/events/envelope.js +84 -0
  143. package/dist/core/events/errors.d.ts +4 -0
  144. package/dist/core/events/errors.js +69 -0
  145. package/dist/core/events/operation-id.d.ts +4 -0
  146. package/dist/core/events/operation-id.js +24 -0
  147. package/dist/core/events/serialize.d.ts +4 -0
  148. package/dist/core/events/serialize.js +199 -0
  149. package/dist/core/events/source.d.ts +16 -0
  150. package/dist/core/events/source.js +31 -0
  151. package/dist/core/events/types.d.ts +68 -0
  152. package/dist/core/events/types.js +11 -0
  153. package/dist/core/exclusive-lock.d.ts +34 -0
  154. package/dist/core/exclusive-lock.js +197 -0
  155. package/dist/core/fs-utils.d.ts +44 -0
  156. package/dist/core/fs-utils.js +208 -0
  157. package/dist/core/help.d.ts +309 -0
  158. package/dist/core/help.js +406 -0
  159. package/dist/core/human/page-catalog.d.ts +57 -0
  160. package/dist/core/human/page-catalog.js +172 -0
  161. package/dist/core/installed-plugins.d.ts +2 -0
  162. package/dist/core/installed-plugins.js +79 -0
  163. package/dist/core/io.d.ts +122 -0
  164. package/dist/core/io.js +373 -0
  165. package/dist/core/keybindings/attach-control.d.ts +49 -0
  166. package/dist/core/keybindings/attach-control.js +42 -0
  167. package/dist/core/keybindings/catalog.d.ts +18 -0
  168. package/dist/core/keybindings/catalog.js +257 -0
  169. package/dist/core/keybindings/types.d.ts +42 -0
  170. package/dist/core/keybindings/types.js +1 -0
  171. package/dist/core/layout.d.ts +36 -0
  172. package/dist/core/layout.js +112 -0
  173. package/dist/core/locked-file.d.ts +27 -0
  174. package/dist/core/locked-file.js +118 -0
  175. package/dist/core/log.d.ts +9 -0
  176. package/dist/core/log.js +89 -0
  177. package/dist/core/manifest.d.ts +5 -0
  178. package/dist/core/manifest.js +15 -0
  179. package/dist/core/plugin-env.d.ts +8 -0
  180. package/dist/core/plugin-env.js +31 -0
  181. package/dist/core/plugin-extensions.d.ts +29 -0
  182. package/dist/core/plugin-extensions.js +191 -0
  183. package/dist/core/plugin-swap-lock.d.ts +9 -0
  184. package/dist/core/plugin-swap-lock.js +31 -0
  185. package/dist/core/preview-result-path.d.ts +4 -0
  186. package/dist/core/preview-result-path.js +26 -0
  187. package/dist/core/profiles/env-store.d.ts +22 -0
  188. package/dist/core/profiles/env-store.js +163 -0
  189. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  190. package/dist/core/profiles/fuzzy-match.js +92 -0
  191. package/dist/core/profiles/manifest.d.ts +120 -0
  192. package/dist/core/profiles/manifest.js +529 -0
  193. package/dist/core/rate-limit-scope.d.ts +25 -0
  194. package/dist/core/rate-limit-scope.js +64 -0
  195. package/dist/core/render.d.ts +12 -0
  196. package/dist/core/render.js +138 -0
  197. package/dist/core/resolver.d.ts +14 -0
  198. package/dist/core/resolver.js +111 -0
  199. package/dist/core/runtime/branded-host.d.ts +55 -0
  200. package/dist/core/runtime/branded-host.js +485 -0
  201. package/dist/core/runtime/broker/daemon-ops.d.ts +65 -0
  202. package/dist/core/runtime/broker/daemon-ops.js +177 -0
  203. package/dist/core/runtime/broker/signal-stream.d.ts +30 -0
  204. package/dist/core/runtime/broker/signal-stream.js +149 -0
  205. package/dist/core/scope.d.ts +32 -0
  206. package/dist/core/scope.js +184 -0
  207. package/dist/core/scoped-state/db.d.ts +17 -0
  208. package/dist/core/scoped-state/db.js +247 -0
  209. package/dist/core/scoped-state/migrate.d.ts +8 -0
  210. package/dist/core/scoped-state/migrate.js +187 -0
  211. package/dist/core/scoped-state/paths.d.ts +9 -0
  212. package/dist/core/scoped-state/paths.js +27 -0
  213. package/dist/core/scoped-state/profiles.d.ts +27 -0
  214. package/dist/core/scoped-state/profiles.js +93 -0
  215. package/dist/core/scoped-state/providers.d.ts +24 -0
  216. package/dist/core/scoped-state/providers.js +19 -0
  217. package/dist/core/scoped-state/schema.d.ts +6 -0
  218. package/dist/core/scoped-state/schema.js +43 -0
  219. package/dist/core/scoped-state/settings.d.ts +28 -0
  220. package/dist/core/scoped-state/settings.js +83 -0
  221. package/dist/core/spaces/open-beneath.d.ts +71 -0
  222. package/dist/core/spaces/open-beneath.js +581 -0
  223. package/dist/core/sqlite-statements.d.ts +4 -0
  224. package/dist/core/sqlite-statements.js +17 -0
  225. package/dist/core/subscription-state.d.ts +121 -0
  226. package/dist/core/subscription-state.js +287 -0
  227. package/dist/core/user-settings.d.ts +377 -0
  228. package/dist/core/user-settings.js +458 -0
  229. package/dist/daemon/broker-signals/bus.d.ts +30 -0
  230. package/dist/daemon/broker-signals/bus.js +87 -0
  231. package/dist/daemon/manage.d.ts +176 -0
  232. package/dist/daemon/manage.js +664 -0
  233. package/dist/daemon/pidfile.d.ts +8 -0
  234. package/dist/daemon/pidfile.js +37 -0
  235. package/dist/daemon/startup-policy.d.ts +1 -0
  236. package/dist/daemon/startup-policy.js +1 -0
  237. package/dist/native/linux.d.ts +29 -0
  238. package/dist/native/linux.js +20 -0
  239. package/dist/shared/env.d.ts +116 -0
  240. package/dist/shared/env.js +271 -0
  241. package/dist/shared/generated-context.d.ts +83 -0
  242. package/dist/shared/generated-context.js +235 -0
  243. package/dist/shared/inbox-entry-body.d.ts +22 -0
  244. package/dist/shared/inbox-entry-body.js +116 -0
  245. package/dist/shared/predicates.d.ts +2 -0
  246. package/dist/shared/predicates.js +4 -0
  247. package/dist/shared/working-activity.d.ts +9 -0
  248. package/dist/shared/working-activity.js +27 -0
  249. package/dist/types.d.ts +562 -0
  250. package/dist/types.js +186 -0
  251. package/package.json +46 -5
@@ -0,0 +1,1388 @@
1
+ // CrtrClient — the typed fetch client over crtrd's API.
2
+ //
3
+ // The root entry is browser-safe: Node's unix-socket fetch implementation and
4
+ // local path resolution live exclusively in `api/node-transport.ts`.
5
+ import { ApiError, isErrorBody } from './errors.js';
6
+ import { routes } from './routes.js';
7
+ import { isSafeNodeId } from './dto/common.js';
8
+ import { isSafeBashJobId } from './dto/bash-jobs.js';
9
+ import { isSafeCronId, } from './dto/crons.js';
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/api/local-socket-fetch');
12
+ /** Default bounded window to wait for `/healthz` to come up after
13
+ * `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
14
+ const HEALTHZ_POLL_WINDOW_MS = 10_000;
15
+ const HEALTHZ_POLL_INTERVAL_MS = 200;
16
+ /** One strict wall-clock availability window shared by local API clients and
17
+ * daemon management. Each probe gets only the budget remaining at its start. */
18
+ export async function waitForDaemonAvailability({ windowMs, probe, initialError, pollIntervalMs = HEALTHZ_POLL_INTERVAL_MS, retry = () => true, now = Date.now, sleep = sleepMs, }) {
19
+ const deadline = now() + windowMs;
20
+ let lastError = initialError;
21
+ for (;;) {
22
+ const remaining = deadline - now();
23
+ if (remaining <= 0)
24
+ throw lastError ?? new Error('crtrd availability window expired without a probe failure');
25
+ try {
26
+ await probe(remaining);
27
+ return;
28
+ }
29
+ catch (error) {
30
+ lastError = error;
31
+ if (!retry(error))
32
+ throw error;
33
+ }
34
+ const afterProbe = deadline - now();
35
+ if (afterProbe <= 0)
36
+ throw lastError;
37
+ await sleep(Math.min(pollIntervalMs, afterProbe));
38
+ }
39
+ }
40
+ /** The one error a call raises when its deadline passes (or a wait would pass it): status 504, code `deadline_exceeded`,
41
+ * `details: { deadline_at, reason, retry_after_s? }`. */
42
+ /** The longest delay a timer accepts (2^31-1 ms, about 24.8 days); a longer deadline would fire at once. */
43
+ export const MAX_TIMER_MS = 2_147_483_647;
44
+ export function deadlineExceeded(deadlineAt, reason = 'elapsed', retryAfterS) {
45
+ const message = reason === 'retry_after'
46
+ ? `the server asked to retry after ${retryAfterS}s, which is later than the call's deadline (${new Date(deadlineAt).toISOString()}); not waiting`
47
+ : `the call's deadline (${new Date(deadlineAt).toISOString()}) passed`;
48
+ return new ApiError(504, 'deadline_exceeded', message, {
49
+ deadline_at: deadlineAt, reason, ...(retryAfterS === undefined ? {} : { retry_after_s: retryAfterS }),
50
+ });
51
+ }
52
+ const budgetExhausted = (deadlineAt) => deadlineExceeded(deadlineAt ?? Date.now());
53
+ export class CrtrClient {
54
+ baseUrl;
55
+ fetch;
56
+ headers;
57
+ autostart;
58
+ timeoutMs;
59
+ maxRetries;
60
+ localSocketTransport;
61
+ onColdSocket;
62
+ coldStartDiagnostic;
63
+ coldStartFailure;
64
+ coldStartPollWindowMs;
65
+ /** Set when the last availability wait ended on a daemon startup failure, whose message already says why. */
66
+ startupFailureReported = false;
67
+ /** Guards against invoking the daemon-start hook more than once per client. */
68
+ coldStartAttempted = false;
69
+ constructor(opts) {
70
+ if (opts.baseUrl === '')
71
+ throw new TypeError('CrtrClient requires baseUrl');
72
+ this.baseUrl = new URL(opts.baseUrl);
73
+ this.fetch = opts.fetch ?? globalThis.fetch;
74
+ this.headers = { ...opts.headers };
75
+ this.autostart = opts.autostart ?? false;
76
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
77
+ this.maxRetries = opts.maxRetries ?? 2;
78
+ this.localSocketTransport = this.fetch[LOCAL_SOCKET_FETCH] === true;
79
+ if (opts.onColdSocket !== undefined)
80
+ this.onColdSocket = opts.onColdSocket;
81
+ if (opts.coldStartDiagnostic !== undefined)
82
+ this.coldStartDiagnostic = opts.coldStartDiagnostic;
83
+ if (opts.coldStartFailure !== undefined)
84
+ this.coldStartFailure = opts.coldStartFailure;
85
+ this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
86
+ }
87
+ // Health / status
88
+ healthz() {
89
+ return this.request('GET', routes.healthz());
90
+ }
91
+ /** One `/healthz` observation without cold-socket recovery. Availability
92
+ * waiters own retry policy and pass their remaining wall-clock budget here. */
93
+ async probeHealthz(timeoutMs) {
94
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
95
+ // A restricted daemon is alive and deliberately answers its health DTO with
96
+ // 503. Preserve that state for daemon startup management instead of treating
97
+ // the expected non-2xx status as a generic API failure.
98
+ if (response.status === 503) {
99
+ try {
100
+ const health = await response.clone().json();
101
+ if (typeof health.startup_blocked === 'string')
102
+ return health;
103
+ }
104
+ catch {
105
+ // `parse` below reports malformed/non-health 503 responses normally.
106
+ }
107
+ }
108
+ return parse(response);
109
+ }
110
+ status() {
111
+ return this.request('GET', routes.status());
112
+ }
113
+ /** Busy and next-due state, the truth behind sandbox sleep and wake. */
114
+ due() {
115
+ return this.request('GET', routes.due());
116
+ }
117
+ /** Ask the daemon to replace itself with a successor running the currently
118
+ * selected runtime generation. Answers before the handover starts, so a
119
+ * caller living inside a node the handover will tear down still gets a
120
+ * settled result. */
121
+ restartDaemon() {
122
+ return this.request('POST', routes.daemonRestart());
123
+ }
124
+ admitDaemon() {
125
+ return this.request('POST', routes.daemonAdmit());
126
+ }
127
+ migrateState(req) {
128
+ return this.request('POST', routes.daemonMigrate(), req);
129
+ }
130
+ // Nodes
131
+ appendNodeLog(id, req) {
132
+ return this.request('POST', routes.nodeLog(this.nodePath(id)), req);
133
+ }
134
+ async putNodeTelemetry(id, req) {
135
+ await this.request('PUT', routes.nodeTelemetry(this.nodePath(id)), req);
136
+ }
137
+ async putNodeRecap(id, req) {
138
+ await this.request('PUT', routes.nodeRecap(this.nodePath(id)), req);
139
+ }
140
+ readNodeInboxReport(id, ref, executionId) {
141
+ return this.request('GET', withQuery(routes.nodeInboxReport(this.nodePath(id)), { ref, execution_id: executionId }));
142
+ }
143
+ readNodePassiveMessage(id, ref) {
144
+ return this.request('GET', withQuery(routes.nodePassiveMessage(this.nodePath(id)), { ref }));
145
+ }
146
+ nodePushedFinal(id, req) {
147
+ return this.request('POST', routes.nodePushedFinal(this.nodePath(id)), req);
148
+ }
149
+ createNode(req) {
150
+ return this.request('POST', routes.nodes(), req);
151
+ }
152
+ /** List canvas nodes with composable row filters. `include: 'activity'` adds
153
+ * latest/canonical reports and pending-human counts in the same response. */
154
+ listNodes(q) {
155
+ return this.request('GET', withQuery(routes.nodes(), q));
156
+ }
157
+ getNode(id) {
158
+ return this.request('GET', routes.node(this.nodePath(id)));
159
+ }
160
+ /** Read a node outcome, optionally awaiting it for at most 25 seconds. This
161
+ * is a GET so the client can safely replay it across daemon handover. */
162
+ getNodeOutcome(id, { waitSeconds } = {}) {
163
+ if (waitSeconds !== undefined && (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 25)) {
164
+ throw new RangeError('waitSeconds must be an integer between 0 and 25');
165
+ }
166
+ return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
167
+ }
168
+ /** Open the raw server-sent event response for a node. The caller owns SSE
169
+ * parsing and must consume or cancel the response body. Streams deliberately
170
+ * have no client wall-clock timeout. */
171
+ getNodeEvents(id, query = {}, options = {}) {
172
+ if (query.after !== undefined && (!Number.isSafeInteger(query.after) || query.after < 0)) {
173
+ throw new RangeError('after must be a non-negative safe integer');
174
+ }
175
+ return this.transport('GET', withQuery(routes.nodeEvents(this.nodePath(id)), query), undefined, {
176
+ ...options,
177
+ headers: { accept: 'text/event-stream', ...(options.headers ?? {}) },
178
+ timeout: 0,
179
+ });
180
+ }
181
+ /** Parsed NDJSON hints, consumed eagerly and line by line. The caller must
182
+ * cancel with `signal` when done; no wall-clock timeout applies to the stream. */
183
+ async *getBrokerSignals(id, executionId, options = {}) {
184
+ const response = await this.send('GET', withQuery(routes.nodeBrokerSignals(this.nodePath(id)), { execution_id: executionId }), undefined, {
185
+ ...options, headers: { accept: 'application/x-ndjson', ...(options.headers ?? {}) }, timeout: 0,
186
+ });
187
+ if (!response.ok) {
188
+ await parse(response);
189
+ return;
190
+ }
191
+ if (response.body === null)
192
+ throw new ApiError(response.status, 'invalid_response', 'broker signal stream has no body');
193
+ const reader = response.body.getReader();
194
+ const decoder = new TextDecoder();
195
+ let buffer = '';
196
+ try {
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+ if (done)
200
+ break;
201
+ buffer += decoder.decode(value, { stream: true });
202
+ let newline;
203
+ while ((newline = buffer.indexOf('\n')) !== -1) {
204
+ const line = buffer.slice(0, newline);
205
+ buffer = buffer.slice(newline + 1);
206
+ if (line !== '')
207
+ yield JSON.parse(line);
208
+ }
209
+ }
210
+ buffer += decoder.decode();
211
+ if (buffer.trim() !== '')
212
+ throw new ApiError(502, 'invalid_response', 'broker signal stream ended mid-line');
213
+ }
214
+ finally {
215
+ await reader.cancel().catch(() => { });
216
+ }
217
+ }
218
+ // Structured requests
219
+ createStructuredRequest(req, options) {
220
+ // A synchronous admission is one held exchange. Replaying a POST after a
221
+ // reset can create work the caller has already stopped waiting for, and
222
+ // cold-socket recovery consumes its fixed end-to-end budget.
223
+ if (req.mode === 'sync')
224
+ return this.sendOnce('POST', '/v1/structured-requests', req, options);
225
+ return this.request('POST', '/v1/structured-requests', req, options);
226
+ }
227
+ getStructuredRequest(requestId, { wait = 0, budgetMs } = {}) {
228
+ if (!Number.isInteger(wait) || wait < 0 || wait > 25) {
229
+ throw new RangeError('wait must be an integer between 0 and 25');
230
+ }
231
+ return this.request('GET', withQuery(`/v1/structured-requests/${this.requestPath(requestId)}`, { wait }), undefined, { budgetMs });
232
+ }
233
+ cancelStructuredRequest(requestId, options) {
234
+ return this.request('POST', `/v1/structured-requests/${this.requestPath(requestId)}/cancel`, {}, options);
235
+ }
236
+ /** Register or replace an armed target for terminal-outcome delivery. */
237
+ registerOutcomeDelivery(id, req) {
238
+ return this.request('PUT', routes.nodeOutcomeDelivery(this.nodePath(id)), req);
239
+ }
240
+ getOutcomeDelivery(id) {
241
+ return this.request('GET', routes.nodeOutcomeDelivery(this.nodePath(id)));
242
+ }
243
+ /** Disarm an unsettled outcome-delivery registration. */
244
+ async disarmOutcomeDelivery(id) {
245
+ await this.request('DELETE', routes.nodeOutcomeDelivery(this.nodePath(id)));
246
+ }
247
+ listBashJobs(id) {
248
+ return this.request('GET', routes.nodeJobs(this.nodePath(id)));
249
+ }
250
+ stopBashJob(id, jobId) {
251
+ return this.request('DELETE', routes.nodeJob(this.nodePath(id), this.jobPath(jobId)));
252
+ }
253
+ sendMessage(id, req) {
254
+ return this.request('POST', routes.nodeMessages(this.nodePath(id)), req);
255
+ }
256
+ /** First-class interrupt (the human Esc): cancels pending undelivered
257
+ * human-send inbox entries, then aborts a live in-flight turn. NEVER
258
+ * revives a dormant target. */
259
+ interruptNode(id) {
260
+ return this.request('POST', routes.nodeInterrupt(this.nodePath(id)), {});
261
+ }
262
+ pushReport(id, req) {
263
+ return this.request('POST', routes.nodeReports(this.nodePath(id)), req);
264
+ }
265
+ submitResult(id, req) {
266
+ return this.request('POST', routes.nodeResult(this.nodePath(id)), req);
267
+ }
268
+ forkNode(id) {
269
+ return this.request('POST', routes.nodeFork(this.nodePath(id)), {});
270
+ }
271
+ reviveNode(id, req) {
272
+ return this.request('POST', routes.nodeRevive(this.nodePath(id)), req ?? {});
273
+ }
274
+ relaunchRoot(id) {
275
+ return this.request('POST', routes.nodeRelaunchRoot(this.nodePath(id)), {});
276
+ }
277
+ bindBrokerSession(id, req) {
278
+ return this.request('POST', routes.nodeBrokerSessionBound(this.nodePath(id)), req);
279
+ }
280
+ settleBroker(id, req) {
281
+ return this.request('POST', routes.nodeBrokerSettle(this.nodePath(id)), req);
282
+ }
283
+ completeBrokerPark(id, req) {
284
+ return this.request('POST', routes.nodeBrokerParkComplete(this.nodePath(id)), req);
285
+ }
286
+ recordBrokerParkActivity(id, req) {
287
+ return this.request('POST', routes.nodeBrokerParkActivity(this.nodePath(id)), req);
288
+ }
289
+ async recordBrokerTelemetry(id, req) {
290
+ await this.request('POST', routes.nodeBrokerTelemetry(this.nodePath(id)), req);
291
+ }
292
+ claimNodeMail(id, req) {
293
+ return this.request('POST', routes.nodeMailClaim(this.nodePath(id)), req);
294
+ }
295
+ acknowledgeNodeMail(id, req) {
296
+ return this.request('POST', routes.nodeMailAcknowledge(this.nodePath(id)), req);
297
+ }
298
+ recordBrokerTurn(id, req) {
299
+ return this.request('POST', routes.brokerTurn(this.nodePath(id)), req);
300
+ }
301
+ mutateBrokerProviderRetry(id, req) {
302
+ return this.request('POST', routes.brokerProviderRetry(this.nodePath(id)), req);
303
+ }
304
+ mutateBrokerFault(id, req) {
305
+ return this.request('POST', routes.brokerFault(this.nodePath(id)), req);
306
+ }
307
+ getBrokerRecovery(id, expectedExecutionId) {
308
+ return this.request('GET', `${routes.brokerRecovery(this.nodePath(id))}?expected_execution_id=${encodeURIComponent(expectedExecutionId)}`);
309
+ }
310
+ recordNodeFault(id, req) {
311
+ return this.request('POST', routes.nodeFault(this.nodePath(id)), req);
312
+ }
313
+ clearNodeFault(id, opts = {}) {
314
+ const query = new URLSearchParams();
315
+ if (opts.link !== undefined)
316
+ query.set('link', opts.link);
317
+ if (opts.preserve_episode !== undefined)
318
+ query.set('preserve_episode', String(opts.preserve_episode));
319
+ const suffix = query.size === 0 ? '' : `?${query.toString()}`;
320
+ return this.request('DELETE', `${routes.nodeFault(this.nodePath(id))}${suffix}`);
321
+ }
322
+ commitBrokerModel(id, req) {
323
+ return this.request('POST', routes.nodeBrokerModel(this.nodePath(id)), req);
324
+ }
325
+ brokerExtensionState(id) {
326
+ return this.request('GET', routes.nodeBrokerExtensionState(this.nodePath(id)));
327
+ }
328
+ commitBrokerGeneratedName(id, req) {
329
+ return this.request('POST', routes.nodeBrokerGeneratedName(this.nodePath(id)), req);
330
+ }
331
+ commitBrokerPersonaAck(id, req) {
332
+ return this.request('POST', routes.nodeBrokerPersonaAck(this.nodePath(id)), req);
333
+ }
334
+ closeNode(id, req) {
335
+ return this.request('POST', routes.nodeClose(this.nodePath(id)), req ?? {});
336
+ }
337
+ recycleNode(id) {
338
+ return this.request('POST', routes.nodeRecycle(this.nodePath(id)), {});
339
+ }
340
+ demoteNode(id) {
341
+ return this.request('POST', routes.nodeDemote(this.nodePath(id)), {});
342
+ }
343
+ promoteNode(id, req) {
344
+ return this.request('POST', routes.nodePromote(this.nodePath(id)), req);
345
+ }
346
+ yieldNode(id, req) {
347
+ return this.request('POST', routes.nodeYield(this.nodePath(id)), req);
348
+ }
349
+ waitNode(id, req) {
350
+ return this.request('POST', routes.nodeWait(this.nodePath(id)), req);
351
+ }
352
+ listKinds() {
353
+ return this.request('GET', routes.kinds());
354
+ }
355
+ /** A model change may first wait up to 30 s for a starting broker's view
356
+ * socket and then 15 s for its ack, so it gets that budget on top. */
357
+ patchConfig(id, patch) {
358
+ return this.request('PATCH', routes.nodeConfig(this.nodePath(id)), patch, patch.model !== undefined ? { timeout: 45_000 + this.timeoutMs } : undefined);
359
+ }
360
+ /** Land + close the node's managed git worktree (spec §6.2). Server-side
361
+ * because it interleaves a canvas WRITE with a git land transaction and
362
+ * crtrd is the repo host (same principle as spawnChild's creation git). */
363
+ closeWorktree(id) {
364
+ return this.request('POST', routes.nodeWorktreeClose(this.nodePath(id)), {});
365
+ }
366
+ abandonWorktree(id, req) {
367
+ return this.request('POST', routes.nodeWorktreeAbandon(this.nodePath(id)), req);
368
+ }
369
+ /** Re-queue a permanently failed push delivery or daemon action with a fresh attempt budget. */
370
+ retryDelivery(deliveryId) {
371
+ return this.request('POST', routes.deliveryRetry(deliveryId), {});
372
+ }
373
+ listQuarantinedWorktrees() {
374
+ return this.request('GET', routes.quarantinedWorktrees());
375
+ }
376
+ /** Effective per-app memory and disk caps and whether this host enforces them. */
377
+ showLimits() {
378
+ return this.request('GET', routes.limits());
379
+ }
380
+ setAppLimits(app, req) {
381
+ return this.request('PUT', routes.limitsApp(app), req);
382
+ }
383
+ removeAppLimits(app, fields) {
384
+ return this.request('DELETE', withQuery(routes.limitsApp(app), { fields: fields.join(',') }));
385
+ }
386
+ /** Every space on the runtime with its size. */
387
+ listSpaces() {
388
+ return this.request('GET', routes.spaces());
389
+ }
390
+ subscribe(id, req) {
391
+ return this.request('POST', routes.nodeSubscriptions(this.nodePath(id)), req);
392
+ }
393
+ // Focuses (viewer registry)
394
+ // The tmux verbs that open/move/close a viewer pane run LOCALLY in the
395
+ // caller's session (placement-tmux); only the canvas.db focus rows route here.
396
+ // Reads GC lazily server-side, so a returned row is always live; a `null` body
397
+ // is the normal "no viewer" answer, not a 404.
398
+ listFocuses() {
399
+ return this.request('GET', routes.focuses());
400
+ }
401
+ focusOf(nodeId) {
402
+ return this.request('GET', withQuery(routes.focusByNode(), { node_id: nodeId }));
403
+ }
404
+ focusByPane(pane) {
405
+ return this.request('GET', withQuery(routes.focusByPane(), { pane }));
406
+ }
407
+ registerFocus(req) {
408
+ return this.request('POST', routes.focuses(), req);
409
+ }
410
+ async setFocusPane(focusId, req) {
411
+ await this.request('PATCH', routes.focus(focusId), req);
412
+ }
413
+ async closeFocus(focusId) {
414
+ await this.request('DELETE', routes.focus(focusId));
415
+ }
416
+ async unsubscribe(id, target) {
417
+ await this.request('DELETE', routes.nodeSubscription(this.nodePath(id), this.nodePath(target)));
418
+ }
419
+ /** Arm one cron (`POST /v1/crons`) — the server mints the cron_id. */
420
+ armCron(req) {
421
+ return this.request('POST', routes.crons(), req);
422
+ }
423
+ /** Crons visible to the caller (`GET /v1/crons`). With `q.profile` that is
424
+ * that profile's crons plus every global one; omit it only for a
425
+ * canvas-home-wide provenance read ("which crons did node X arm"). */
426
+ listCrons(q) {
427
+ return this.request('GET', withQuery(routes.crons(), q));
428
+ }
429
+ /** One cron with its run-log ring (`GET /v1/crons/:cronId`). */
430
+ showCron(cronId, q) {
431
+ return this.request('GET', withQuery(routes.cron(this.cronPath(cronId)), q));
432
+ }
433
+ /** Pause one cron (`POST /v1/crons/:cronId/pause`) — stops firing, keeps config+history. */
434
+ pauseCron(cronId, q) {
435
+ return this.request('POST', withQuery(routes.cronPause(this.cronPath(cronId)), q), {});
436
+ }
437
+ /** Resume one paused cron (`POST /v1/crons/:cronId/resume`). */
438
+ resumeCron(cronId, q) {
439
+ return this.request('POST', withQuery(routes.cronResume(this.cronPath(cronId)), q), {});
440
+ }
441
+ /** Run one cron NOW, out of band (`POST /v1/crons/:cronId/run`) — synchronous:
442
+ * resolves with the settled run record after the subprocess closes. Does not
443
+ * advance the schedule or consume a one-shot; never escalates. */
444
+ runCron(cronId, q) {
445
+ return this.request('POST', withQuery(routes.cronRun(this.cronPath(cronId)), q), {});
446
+ }
447
+ /** Cancel one cron (`DELETE /v1/crons/:cronId`, idempotent). */
448
+ async cancelCron(cronId, q) {
449
+ await this.request('DELETE', withQuery(routes.cron(this.cronPath(cronId)), q));
450
+ }
451
+ /** Bare eligibility poke (`POST /v1/crons/poke`): re-dues every held active
452
+ * cron now — "something changed; re-check now". Canvas-wide, label-free,
453
+ * idempotent, and free when nothing is held. */
454
+ pokeCrons() {
455
+ return this.request('POST', routes.cronsPoke(), {});
456
+ }
457
+ ensureAttach(id, req) {
458
+ return this.request('POST', routes.nodeAttach(this.nodePath(id)), req ?? {});
459
+ }
460
+ // Reads / feed
461
+ getReports(id, q) {
462
+ return this.request('GET', withQuery(routes.nodeReports(this.nodePath(id)), q));
463
+ }
464
+ getTranscript(id, q) {
465
+ return this.request('GET', withQuery(routes.nodeTranscript(this.nodePath(id)), q));
466
+ }
467
+ getSnapshot(id) {
468
+ return this.request('GET', routes.nodeSnapshot(this.nodePath(id)));
469
+ }
470
+ /** The node-config subject substrate gates evaluate against. */
471
+ nodeSubject(id) {
472
+ return this.request('GET', routes.nodeSubject(this.nodePath(id)));
473
+ }
474
+ getNodeMessages(id, q) {
475
+ return this.request('GET', withQuery(routes.nodeMessages(this.nodePath(id)), q));
476
+ }
477
+ /** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
478
+ * assembled system prompt. For exports; `getSnapshot` is for renderers. */
479
+ getSession(id) {
480
+ return this.request('GET', routes.nodeSession(this.nodePath(id)));
481
+ }
482
+ /** What a non-terminal chat surface may offer for this node: the chat-capable
483
+ * slash commands its live engine registered, and the memory documents an
484
+ * inline `/name` token resolves to. Never revives — a node whose broker is
485
+ * not live answers `broker_live: false` with empty arrays. */
486
+ getChatInventory(id) {
487
+ return this.request('GET', routes.nodeChatInventory(this.nodePath(id)));
488
+ }
489
+ getProspectiveChatInventory(q) {
490
+ return this.request('GET', withQuery(routes.prospectiveChatInventory(), q));
491
+ }
492
+ getContext(id) {
493
+ return this.request('GET', routes.nodeContext(this.nodePath(id)));
494
+ }
495
+ // Host file read
496
+ /** Read an absolute host path (capped, `truncated` when clipped). */
497
+ peekFile(path, encoding, options) {
498
+ return this.request('GET', withQuery(routes.filePeek(), { path, encoding }), undefined, options);
499
+ }
500
+ writeFile(path, content, encoding, options) {
501
+ return this.request('POST', routes.fileWrite(), { path, content, encoding }, options);
502
+ }
503
+ listFiles(path, limit, options) {
504
+ return this.request('GET', withQuery(routes.fileList(), { path, limit }), undefined, options);
505
+ }
506
+ runBash(params, options) {
507
+ return this.request('POST', routes.bash(), params, options);
508
+ }
509
+ // Canvas objects and documents
510
+ /** `crtr canvas` shared verbs. A ref is a name (with or without `[[ ]]`) or a raw object id. */
511
+ objects = {
512
+ read: (ref, query) => this.request('GET', withQuery(routes.object(ref), query)),
513
+ list: (query) => this.request('GET', withQuery(routes.objects(), query)),
514
+ search: (request) => this.request('POST', routes.objectsSearch(), request),
515
+ watch: (ref, request = {}) => this.request('POST', routes.objectWatch(ref), request),
516
+ unwatch: (ref, query) => this.request('DELETE', withQuery(routes.objectWatch(ref), query)),
517
+ edges: (ref, query) => this.request('GET', withQuery(routes.objectEdges(ref), query)),
518
+ };
519
+ /** `crtr doc` write verbs. */
520
+ docs = {
521
+ write: (request) => this.request('POST', routes.docs(), request),
522
+ edit: (ref, request) => this.request('PATCH', routes.doc(ref), request),
523
+ move: (ref, request) => this.request('POST', routes.docMove(ref), request),
524
+ delete: (ref) => this.request('DELETE', routes.doc(ref)),
525
+ history: (ref, query) => this.request('GET', withQuery(routes.docHistory(ref), query)),
526
+ lint: (request = {}) => this.request('POST', routes.docsLint(), request),
527
+ };
528
+ /** Package (builtin and plugin) documents. */
529
+ documents = {
530
+ reconcilePackages: () => this.request('POST', routes.documentsReconcilePackages()),
531
+ };
532
+ /** The broker's delivery of one node activity. */
533
+ nodeDelivery(id, request) {
534
+ return this.request('POST', routes.nodeDelivery(this.nodePath(id)), request);
535
+ }
536
+ /** A person's dry run of one delivery activity, for a live or hypothetical node. Writes nothing. */
537
+ deliveryDryRun(request) {
538
+ return this.request('POST', routes.deliveryDryRun(), request);
539
+ }
540
+ /** Record a backgrounded bash command as a job object. */
541
+ recordBackgroundedJob(id, jobId, request) {
542
+ return this.request('POST', routes.nodeJobBackground(this.nodePath(id), this.jobPath(jobId)), request);
543
+ }
544
+ // Profiles
545
+ /** Create-or-return by name. Supplied `projects` are shape-checked even when
546
+ * the profile already exists; their directories are only required to exist
547
+ * when this call creates the profile. */
548
+ ensureProfile(name, req) {
549
+ return this.request('PUT', routes.profile(name), req ?? {});
550
+ }
551
+ /** Create a profile of the caller's app; a name it already holds is refused. */
552
+ createProfile(req) {
553
+ return this.request('POST', routes.profiles(), req);
554
+ }
555
+ listProfiles() {
556
+ return this.request('GET', routes.profiles());
557
+ }
558
+ /** Rename, set the default kind, or add or remove one project. */
559
+ updateProfile(name, req) {
560
+ return this.request('PATCH', routes.profile(name), req);
561
+ }
562
+ listAppEnv() {
563
+ return this.request('GET', routes.appEnv());
564
+ }
565
+ setAppEnv(variable, value) {
566
+ return this.request('PUT', routes.appEnvVar(variable), { value });
567
+ }
568
+ removeAppEnv(variable) {
569
+ return this.request('DELETE', routes.appEnvVar(variable));
570
+ }
571
+ listProfileEnv(name) {
572
+ return this.request('GET', routes.profileEnv(name));
573
+ }
574
+ setProfileEnv(name, variable, value) {
575
+ return this.request('PUT', routes.profileEnvVar(name, variable), { value });
576
+ }
577
+ removeProfileEnv(name, variable) {
578
+ return this.request('DELETE', routes.profileEnvVar(name, variable));
579
+ }
580
+ getProfile(name) {
581
+ return this.request('GET', routes.profile(name));
582
+ }
583
+ pauseProfile(name) {
584
+ return this.request('POST', routes.profilePause(name), {});
585
+ }
586
+ resumeProfile(name) {
587
+ return this.request('POST', routes.profileResume(name), {});
588
+ }
589
+ /** Merge and remove entries in a profile's metadata map. */
590
+ updateProfileMetadata(name, req) {
591
+ return this.request('PATCH', routes.profileMetadata(name), req);
592
+ }
593
+ /** Force-delete or detach one profile by exact id or unique name. */
594
+ deleteProfile(name, req) {
595
+ return this.request('DELETE', routes.profile(name), req);
596
+ }
597
+ // Model auth
598
+ listModelAuth() {
599
+ return this.request('GET', routes.modelAuths());
600
+ }
601
+ getModelAuthReadiness(query) {
602
+ return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
603
+ }
604
+ installCredential(provider, req) {
605
+ return this.request('PUT', routes.modelAuth(provider), req);
606
+ }
607
+ removeCredential(provider) {
608
+ return this.request('DELETE', routes.modelAuth(provider));
609
+ }
610
+ /** `model_auth.start` — the daemon runs the vendor OAuth; the caller only shows `url`. */
611
+ startModelAuth(req) {
612
+ return this.request('POST', routes.modelAuthFlows(), req, { timeout: 30_000 + this.timeoutMs });
613
+ }
614
+ /** `model_auth.complete` — hand the daemon the code the vendor showed. */
615
+ completeModelAuth(flowId, req) {
616
+ return this.request('POST', routes.modelAuthFlowComplete(flowId), req, { timeout: 35_000 + this.timeoutMs });
617
+ }
618
+ /** `model_auth.cancel` — end an in-flight flow the person gave up on; it writes nothing. */
619
+ cancelModelAuth(flowId) {
620
+ return this.request('POST', routes.modelAuthFlowCancel(flowId), {});
621
+ }
622
+ /** A flow's state; `waitSeconds` (1-25) long-polls until it leaves `pending`. */
623
+ getModelAuthFlow(flowId, waitSeconds) {
624
+ return this.request('GET', withQuery(routes.modelAuthFlow(flowId), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
625
+ }
626
+ // Daemon-owned document reviews and comments
627
+ createReview(req) {
628
+ return this.request('POST', routes.humanReviews(), req);
629
+ }
630
+ listReviews(query) {
631
+ return this.request('GET', withQuery(routes.humanReviews(), query));
632
+ }
633
+ getReview(reviewId) {
634
+ return this.request('GET', routes.humanReview(this.reviewPath(reviewId)));
635
+ }
636
+ submitReview(reviewId) {
637
+ return this.request('POST', routes.humanReviewSubmit(this.reviewPath(reviewId)), {});
638
+ }
639
+ cancelReview(reviewId, req = {}) {
640
+ return this.request('POST', routes.humanReviewCancel(this.reviewPath(reviewId)), req);
641
+ }
642
+ getReviewDocumentBase(reviewId) {
643
+ return this.request('GET', routes.humanReviewDocument(this.reviewPath(reviewId)));
644
+ }
645
+ createReviewComment(reviewId, req) {
646
+ const path = reviewId === undefined ? 'self' : this.reviewPath(reviewId);
647
+ return this.request('POST', routes.humanReviewComments(path), req);
648
+ }
649
+ listReviewComments(reviewId, query) {
650
+ const path = reviewId === undefined ? 'self' : this.reviewPath(reviewId);
651
+ return this.request('GET', withQuery(routes.humanReviewComments(path), query));
652
+ }
653
+ readReviewCommentEvents(reviewId, query) {
654
+ return this.request('GET', withQuery(routes.humanReviewCommentEvents(this.reviewPath(reviewId)), query));
655
+ }
656
+ updateReviewCommentRanges(reviewId, req) {
657
+ return this.request('POST', routes.humanReviewCommentRanges(this.reviewPath(reviewId)), req);
658
+ }
659
+ getReviewComment(commentId) {
660
+ return this.request('GET', routes.humanComment(this.commentPath(commentId)));
661
+ }
662
+ editReviewComment(commentId, req) {
663
+ return this.request('POST', routes.humanCommentEdit(this.commentPath(commentId)), req);
664
+ }
665
+ resolveReviewComment(commentId, req = {}) {
666
+ return this.request('POST', routes.humanCommentResolve(this.commentPath(commentId)), req);
667
+ }
668
+ reopenReviewComment(commentId, req = {}) {
669
+ return this.request('POST', routes.humanCommentReopen(this.commentPath(commentId)), req);
670
+ }
671
+ deleteReviewComment(commentId, req = {}) {
672
+ return this.request('POST', routes.humanCommentDelete(this.commentPath(commentId)), req);
673
+ }
674
+ forkReviewComment(commentId) {
675
+ return this.request('POST', routes.humanCommentFork(this.commentPath(commentId)), {});
676
+ }
677
+ // ---- Humanloop inbox (crouter cloud crouter-inbox v1, inbox-contract.md §A) --
678
+ /** Pending page/review tickets across every available crouter-owned
679
+ * humanloop root. */
680
+ listHumanInbox() {
681
+ return this.request('GET', routes.humanInbox());
682
+ }
683
+ /** Read one page ticket by its opaque ticket id (pending, resolved, or canceled). */
684
+ getHumanInboxPage(ticketId) {
685
+ return this.request('GET', routes.humanInboxTicket(this.ticketId(ticketId)));
686
+ }
687
+ /** All page tickets, oldest first (pending, resolved, or canceled) —
688
+ * home-wide, or narrowed to one raising node when `nodeId` is given. */
689
+ getInboxHistory(nodeId) {
690
+ if (nodeId === undefined) {
691
+ return this.request('GET', routes.humanInbox() + '/history');
692
+ }
693
+ if (typeof nodeId !== 'string' || nodeId === '') {
694
+ throw new TypeError(`invalid node id: ${JSON.stringify(nodeId)}`);
695
+ }
696
+ return this.request('GET', withQuery(routes.humanInbox() + '/history', { node_id: nodeId }));
697
+ }
698
+ /** Submit responses for a page ticket. Single-assignment server-side: a competing
699
+ * resolution races to `ticket_already_resolved`. */
700
+ respondHumanInboxPage(ticketId, request) {
701
+ return this.request('POST', routes.humanInboxRespond(this.ticketId(ticketId)), request);
702
+ }
703
+ /** Autosave partial page work. Omitted slots are permitted in progress updates. */
704
+ postInboxProgress(ticketId, responses) {
705
+ return this.request('POST', routes.humanInboxProgress(this.ticketId(ticketId)), { responses });
706
+ }
707
+ /** Get the published response for a resolved page ticket. 404 if pending or canceled. */
708
+ getInboxResponse(ticketId) {
709
+ return this.request('GET', routes.humanInboxResponse(this.ticketId(ticketId)));
710
+ }
711
+ /** Cancel a ticket (terminal response, never deletion). */
712
+ cancelHumanInboxTicket(ticketId, request) {
713
+ return this.request('POST', routes.humanInboxCancel(this.ticketId(ticketId)), request ?? {});
714
+ }
715
+ // Durable programmatic human requests
716
+ /** Create one durable human request. The minted `request_id` identifies the
717
+ * request; inbox presentation has its own ticket id. An unresolvable
718
+ * `action.name` is rejected before the page is published, leaving no inbox
719
+ * row behind. */
720
+ createHumanRequest(request) {
721
+ return this.request('POST', routes.humanRequests(), request);
722
+ }
723
+ /** Product page components registered on the runtime (config + enabled plugins). */
724
+ listPageComponents() {
725
+ return this.request('GET', routes.humanComponents());
726
+ }
727
+ /** Read one request: its current state, its answer when answered, and the
728
+ * delivery state of its completion action when it bound one. */
729
+ getHumanRequest(requestId) {
730
+ return this.request('GET', routes.humanRequest(this.requestPath(requestId)));
731
+ }
732
+ /** Revise a pending request's page in place. Identity, provenance, and the
733
+ * frozen action binding are preserved; a settled request refuses. */
734
+ replaceHumanRequest(requestId, request) {
735
+ return this.request('POST', routes.humanRequestReplace(this.requestPath(requestId)), request);
736
+ }
737
+ /** Settle a request `answered` programmatically. Races a human answer to the
738
+ * same first-writer-wins result. */
739
+ respondHumanRequest(requestId, request) {
740
+ return this.request('POST', routes.humanRequestRespond(this.requestPath(requestId)), request);
741
+ }
742
+ /** The recipient surface closing a request without answering. */
743
+ dismissHumanRequest(requestId, request = {}) {
744
+ return this.request('POST', routes.humanRequestDismiss(this.requestPath(requestId)), request);
745
+ }
746
+ /** The requester withdrawing its own request. */
747
+ cancelHumanRequest(requestId, request = {}) {
748
+ return this.request('POST', routes.humanRequestCancel(this.requestPath(requestId)), request);
749
+ }
750
+ /** Resolve one page feedback comment — the bound companion's report that it
751
+ * has been dealt with. `nodeId` names the caller; the daemon refuses any
752
+ * node but the ticket's companion. Terminal for the comment; appends no
753
+ * chat turn. */
754
+ resolvePageFeedbackComment(ticketId, commentId, nodeId) {
755
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(commentId)) {
756
+ throw new TypeError(`invalid feedback comment id: ${JSON.stringify(commentId)}`);
757
+ }
758
+ return this.request('POST', routes.humanInboxFeedbackResolve(this.ticketId(ticketId), commentId), { node_id: nodeId });
759
+ }
760
+ // Canvas reads / maintenance
761
+ /** Composed client-side from `GET /v1/nodes` + `GET /v1/status` (spec §6.3 —
762
+ * the dashboard is absorbed into those two reads; there is no single route).
763
+ * `generated_at` is the client-side capture instant of the composition. */
764
+ async dashboard(q) {
765
+ const listQuery = q?.under !== undefined ? { under: q.under } : undefined;
766
+ const [nodes, status] = await Promise.all([this.listNodes(listQuery), this.status()]);
767
+ return { nodes, counts: status.node_counts, generated_at: new Date().toISOString() };
768
+ }
769
+ attention() {
770
+ return this.request('GET', routes.canvasAttention());
771
+ }
772
+ /** Per-node pending-ticket counts for a bounded viewer slice. */
773
+ attentionCounts(node_ids) {
774
+ const body = { node_ids };
775
+ return this.request('POST', routes.canvasAttentionCounts(), body);
776
+ }
777
+ /** Ranked/filtered content search over the per-cwd episodic corpus
778
+ * (`crtr canvas history search`). Optional query: ranked when present,
779
+ * recency browse when omitted. POST-bodied — the query carries arrays and
780
+ * free text; the whole search executes server-side (spec §6.3). */
781
+ historySearch(q) {
782
+ return this.request('POST', routes.canvasHistorySearch(), q);
783
+ }
784
+ /** Required-pattern line-hit search over the per-cwd episodic corpus
785
+ * (`crtr canvas history grep`). Distinct stable schema from `historySearch`
786
+ * — POST-bodied for the same reasons. */
787
+ historyGrep(q) {
788
+ return this.request('POST', routes.canvasHistoryGrep(), q);
789
+ }
790
+ /** Resolve one `<node-id>:<relpath>` history ref to its full body
791
+ * (`crtr canvas history read`). */
792
+ historyRead(q) {
793
+ return this.request('GET', withQuery(routes.canvasHistoryRead(), q));
794
+ }
795
+ /** Grouped-count projection over the per-cwd episodic corpus
796
+ * (`crtr canvas history stats`). Same filters as search/grep, aggregate
797
+ * result instead of a hit page — POST-bodied for the same reasons. */
798
+ historyStats(q) {
799
+ return this.request('POST', routes.canvasHistoryStats(), q);
800
+ }
801
+ /** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
802
+ * distinct from the per-node `getSnapshot`. */
803
+ canvasSnapshot() {
804
+ return this.request('GET', routes.canvasSnapshot());
805
+ }
806
+ /** The Analytics page model (`GET /v1/canvas/analytics`). The
807
+ * daemon scans transcripts for the window: a cold 7d scan of ~2 GB of
808
+ * transcripts measured 10 s, and with scan workers capped at 4 a queued
809
+ * request waits about one scan more. 60 s is 3x that worst case; past it the
810
+ * page shows the error instead of computing forever. */
811
+ analytics(q) {
812
+ return this.request('GET', withQuery(routes.canvasAnalytics(), q), undefined, { timeout: 60_000 });
813
+ }
814
+ /** Record memory reads/loads for one node (`POST /v1/nodes/:id/memory-reads`). */
815
+ async recordMemoryReads(nodeId, body) {
816
+ await this.request('POST', routes.nodeMemoryReads(this.nodePath(nodeId)), body);
817
+ }
818
+ /** The lean, set-based topology roster (`GET /v1/canvas/roster`) — exactly
819
+ * two indexed queries server-side, no per-row enrichment. The recurring
820
+ * poll target for attach/browser topology; use `canvasSnapshot` for the
821
+ * enriched on-demand view. */
822
+ canvasRoster() {
823
+ return this.request('GET', routes.canvasRoster());
824
+ }
825
+ /** Explicitly compressed browse snapshot; the Node-only canvas source
826
+ * decompresses it because socketFetch does not interpret content-encoding. */
827
+ async canvasBrowseCompressed() {
828
+ const response = await this.send('GET', routes.canvasBrowse());
829
+ if (!response.ok)
830
+ await parse(response); // retain the normal structured API error
831
+ return new Uint8Array(await response.arrayBuffer());
832
+ }
833
+ /** The lean attach-graph projection: display rows, topology, and focus state
834
+ * in one daemon-owned read rather than the full node-summary list. */
835
+ canvasGraph() {
836
+ return this.request('GET', routes.canvasGraph());
837
+ }
838
+ prune(req) {
839
+ return this.request('POST', routes.canvasPrune(), req);
840
+ }
841
+ // Escape hatch
842
+ /** Raw request for routes not yet method-wrapped. Applies the same
843
+ * autostart + retry + error-mapping semantics. */
844
+ async request(method, path, body, opts) {
845
+ try {
846
+ // The deadline is fixed once here so the headers, the retries and the reading of the body share it.
847
+ const resolved = opts === undefined ? undefined : this.resolveBudget(opts);
848
+ const response = await this.send(method, path, body, resolved);
849
+ return await parse(response, resolved?.deadlineAt);
850
+ }
851
+ catch (error) {
852
+ throw toTransportApiError(error, opts?.signal);
853
+ }
854
+ }
855
+ /** Open a live response with the normal cold-socket and retry policy, without consuming its body. */
856
+ async rawResponse(method, path, opts) {
857
+ try {
858
+ return await this.send(method, path, undefined, opts);
859
+ }
860
+ catch (error) {
861
+ throw toTransportApiError(error, opts?.signal);
862
+ }
863
+ }
864
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
865
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
866
+ * only — POST/PATCH are never replayed once a request has actually been
867
+ * sent). No JSON parse; every wrapper goes through here. */
868
+ async send(method, path, body, callOpts = {}) {
869
+ const opts = this.resolveBudget(callOpts);
870
+ const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
871
+ const maxRetries = opts.maxRetries ?? this.maxRetries;
872
+ for (let attempt = 0;; attempt++) {
873
+ let response;
874
+ try {
875
+ response = await this.transport(method, path, body, opts);
876
+ }
877
+ catch (err) {
878
+ if (this.isColdSocketError(err)) {
879
+ try {
880
+ await this.handleColdSocket(err, opts.deadlineAt);
881
+ response = await this.transport(method, path, body, opts);
882
+ }
883
+ catch (recoveryError) {
884
+ // The caller's deadline, not the last probe failure, is why the call stopped when it has passed.
885
+ if (opts.deadlineAt !== undefined && Date.now() >= opts.deadlineAt && !isAbortError(recoveryError, opts.signal))
886
+ throw deadlineExceeded(opts.deadlineAt);
887
+ throw toTransportApiError(recoveryError, opts.signal);
888
+ }
889
+ }
890
+ else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
891
+ return await this.rideOutInterruptedRequest(method, path, body, opts);
892
+ }
893
+ else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
894
+ await sleepWithin(retryDelayMs(attempt + 1), opts);
895
+ continue;
896
+ }
897
+ else {
898
+ throw toTransportApiError(err, opts.signal);
899
+ }
900
+ }
901
+ if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
902
+ await drainResponseBody(response, opts.deadlineAt);
903
+ await sleepWithin(retryDelayMs(attempt + 1), opts, retryAfterHeaderS(response));
904
+ continue;
905
+ }
906
+ return response;
907
+ }
908
+ }
909
+ // internals
910
+ resolveBudget(opts) {
911
+ const { budgetMs, deadlineAt, ...rest } = opts;
912
+ if (budgetMs === undefined && deadlineAt === undefined)
913
+ return rest;
914
+ if (budgetMs !== undefined && (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > MAX_TIMER_MS))
915
+ throw new RangeError(`budgetMs must be a positive finite number of at most ${MAX_TIMER_MS}`);
916
+ if (deadlineAt !== undefined && (!Number.isFinite(deadlineAt) || deadlineAt - Date.now() > MAX_TIMER_MS))
917
+ throw new RangeError(`deadlineAt must be a finite epoch-millisecond instant at most ${MAX_TIMER_MS} ms from now`);
918
+ return { ...rest, deadlineAt: Math.min(deadlineAt ?? Infinity, budgetMs === undefined ? Infinity : Date.now() + budgetMs) };
919
+ }
920
+ async awaitWithinBudget(promise, deadlineAt) {
921
+ if (deadlineAt === undefined)
922
+ return promise;
923
+ const remainingMs = deadlineAt - Date.now();
924
+ if (remainingMs <= 0)
925
+ throw budgetExhausted(deadlineAt);
926
+ let timer;
927
+ try {
928
+ return await Promise.race([
929
+ promise,
930
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(budgetExhausted(deadlineAt)), remainingMs); }),
931
+ ]);
932
+ }
933
+ finally {
934
+ if (timer !== undefined)
935
+ clearTimeout(timer);
936
+ }
937
+ }
938
+ /** One transport attempt, deliberately without retry, cold-socket, or interrupted-response recovery. */
939
+ async sendOnce(method, path, body, callOpts = {}) {
940
+ const opts = this.resolveBudget(callOpts);
941
+ try {
942
+ return await parse(await this.transport(method, path, body, opts));
943
+ }
944
+ catch (error) {
945
+ throw toTransportApiError(error, opts.signal);
946
+ }
947
+ }
948
+ nodePath(id) {
949
+ if (!isSafeNodeId(id)) {
950
+ throw new ApiError(400, 'invalid_node_id', `invalid node id: ${JSON.stringify(id)}`);
951
+ }
952
+ return id;
953
+ }
954
+ /** Validate a background job id before route construction. Job ids arrive
955
+ * from the daemon's file-backed roster and must remain one path segment. */
956
+ jobPath(id) {
957
+ if (!isSafeBashJobId(id)) {
958
+ throw new ApiError(400, 'invalid_job_id', `invalid background job id: ${JSON.stringify(id)}`);
959
+ }
960
+ return id;
961
+ }
962
+ /** Validate a cron id before route construction — `routes.ts` interpolates
963
+ * it raw, so a value carrying `/`, whitespace or `?` would corrupt the
964
+ * request line rather than 404 cleanly. Mirrors `nodePath`. */
965
+ cronPath(id) {
966
+ if (!isSafeCronId(id)) {
967
+ throw new ApiError(400, 'invalid_cron_id', `invalid cron id: ${JSON.stringify(id)}`);
968
+ }
969
+ return id;
970
+ }
971
+ /** Validate a request id before route construction. Requests use the daemon's
972
+ * node-id-shaped identifiers, while inbox presentation uses a separate ticket
973
+ * id. A malformed request id is a server-rejectable request. */
974
+ requestPath(id) {
975
+ if (!isSafeNodeId(id)) {
976
+ throw new ApiError(400, 'invalid_request_id', `invalid request id: ${JSON.stringify(id)}`);
977
+ }
978
+ return id;
979
+ }
980
+ /** Validate an opaque inbox ticket id before route construction. A local
981
+ * shape violation is a caller bug, not a server-rejectable request — throws
982
+ * `TypeError` (matching the existing safe-segment discipline of a local
983
+ * precondition, distinct from `nodePath`'s `ApiError` because that one IS a
984
+ * request the server could plausibly receive and reject itself). */
985
+ ticketId(id) {
986
+ if (!/^[a-f0-9]{64}$/.test(id)) {
987
+ throw new TypeError(`invalid inbox ticket id: ${JSON.stringify(id)}`);
988
+ }
989
+ return id;
990
+ }
991
+ /** Validate a review id before route construction (D-A6). Reviews are minted
992
+ * and addressed like node ids, but arrive from agent argv, so a malformed
993
+ * one is a plausible request the server would also reject — `ApiError`,
994
+ * not `TypeError`, matching `nodePath`. */
995
+ reviewPath(id) {
996
+ if (!isSafeNodeId(id)) {
997
+ throw new ApiError(400, 'invalid_review_id', `invalid review id: ${JSON.stringify(id)}`);
998
+ }
999
+ return id;
1000
+ }
1001
+ /** Validate a comment id before route construction (D-A6). Comment ids are
1002
+ * daemon-minted 32-char lowercase hex, but — like `reviewPath` — arrive from
1003
+ * agent argv, so a bad one is a plausible request the server would also
1004
+ * reject, not a caller-bug `TypeError` like `ticketId`. */
1005
+ commentPath(id) {
1006
+ if (!/^[a-f0-9]{32}$/.test(id)) {
1007
+ throw new ApiError(400, 'invalid_comment_id', `invalid comment id: ${JSON.stringify(id)}`);
1008
+ }
1009
+ return id;
1010
+ }
1011
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
1012
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
1013
+ * that wants to stream (SSE, later) never waits on a buffered body. */
1014
+ transport(method, path, body, opts = {}) {
1015
+ const remainingBudgetMs = opts.deadlineAt === undefined ? undefined : opts.deadlineAt - Date.now();
1016
+ if (remainingBudgetMs !== undefined && remainingBudgetMs <= 0)
1017
+ return Promise.reject(budgetExhausted(opts.deadlineAt));
1018
+ const payload = body === undefined ? undefined : JSON.stringify(body);
1019
+ const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
1020
+ if (payload !== undefined)
1021
+ headers['content-type'] = 'application/json';
1022
+ const baseTimeoutMs = opts.timeout ?? this.timeoutMs;
1023
+ const timeoutMs = remainingBudgetMs === undefined
1024
+ ? baseTimeoutMs
1025
+ : baseTimeoutMs > 0 ? Math.min(baseTimeoutMs, remainingBudgetMs) : remainingBudgetMs;
1026
+ const controller = new AbortController();
1027
+ // The deadline, not the per-attempt timeout, ended this attempt when it was the nearer of the two.
1028
+ const deadlineEnds = remainingBudgetMs !== undefined && (baseTimeoutMs <= 0 || remainingBudgetMs <= baseTimeoutMs);
1029
+ const timer = timeoutMs > 0
1030
+ ? setTimeout(() => controller.abort(deadlineEnds ? budgetExhausted(opts.deadlineAt) : new DOMException('request timed out', 'TimeoutError')), timeoutMs)
1031
+ : undefined;
1032
+ const externalSignal = opts.signal;
1033
+ const onExternalAbort = () => controller.abort(externalSignal?.reason);
1034
+ if (externalSignal !== undefined) {
1035
+ if (externalSignal.aborted)
1036
+ controller.abort(externalSignal.reason);
1037
+ else
1038
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
1039
+ }
1040
+ const finish = () => {
1041
+ if (timer !== undefined)
1042
+ clearTimeout(timer);
1043
+ externalSignal?.removeEventListener('abort', onExternalAbort);
1044
+ };
1045
+ return abortable(this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal }), controller.signal)
1046
+ .then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
1047
+ finish();
1048
+ throw error;
1049
+ });
1050
+ }
1051
+ retainRequestLifetime(response, finish, abortReason) {
1052
+ if (response.body === null) {
1053
+ finish();
1054
+ return response;
1055
+ }
1056
+ const reader = response.body.getReader();
1057
+ let finished = false;
1058
+ const close = () => {
1059
+ if (finished)
1060
+ return;
1061
+ finished = true;
1062
+ finish();
1063
+ };
1064
+ const body = new ReadableStream({
1065
+ async pull(controller) {
1066
+ try {
1067
+ const { done, value } = await reader.read();
1068
+ if (done) {
1069
+ close();
1070
+ controller.close();
1071
+ }
1072
+ else {
1073
+ controller.enqueue(value);
1074
+ }
1075
+ }
1076
+ catch (error) {
1077
+ close();
1078
+ controller.error(abortReason() ?? error);
1079
+ }
1080
+ },
1081
+ async cancel(reason) {
1082
+ close();
1083
+ await reader.cancel(reason);
1084
+ },
1085
+ });
1086
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
1087
+ }
1088
+ isColdSocketError(err) {
1089
+ const code = transportErrorCode(err);
1090
+ return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
1091
+ }
1092
+ /** A connection torn down MID-request (Node's "socket hang up" / a broken
1093
+ * pipe). It establishes only that the response was interrupted, not why. */
1094
+ isInterruptedSocketError(err) {
1095
+ const code = transportErrorCode(err);
1096
+ return code === 'ECONNRESET' || code === 'EPIPE';
1097
+ }
1098
+ /** Wait for the local API after an interrupted response. GET/HEAD can retry
1099
+ * because they are idempotent. A mutation may already have been applied, so
1100
+ * it never replays. */
1101
+ async rideOutInterruptedRequest(method, path, body, opts = {}) {
1102
+ try {
1103
+ await this.awaitAvailability(undefined, opts.deadlineAt);
1104
+ }
1105
+ catch (error) {
1106
+ throw toTransportApiError(error, opts.signal);
1107
+ }
1108
+ if (method !== 'GET' && method !== 'HEAD') {
1109
+ throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
1110
+ }
1111
+ try {
1112
+ return await this.transport(method, path, body, opts);
1113
+ }
1114
+ catch (err) {
1115
+ throw toTransportApiError(err, opts.signal);
1116
+ }
1117
+ }
1118
+ async awaitAvailability(initialError, deadlineAt) {
1119
+ const remainingBudgetMs = deadlineAt === undefined ? undefined : deadlineAt - Date.now();
1120
+ if (remainingBudgetMs !== undefined && remainingBudgetMs <= 0)
1121
+ throw budgetExhausted(deadlineAt);
1122
+ let startupFailure;
1123
+ try {
1124
+ await waitForDaemonAvailability({
1125
+ windowMs: Math.min(this.coldStartPollWindowMs, remainingBudgetMs ?? this.coldStartPollWindowMs),
1126
+ initialError,
1127
+ retry: (error) => error !== startupFailure,
1128
+ probe: async (timeoutMs) => {
1129
+ const failure = this.coldStartFailure?.();
1130
+ if (failure !== undefined) {
1131
+ startupFailure = new ApiError(503, 'daemon_unavailable', `crtrd failed to start: ${failure}`);
1132
+ throw startupFailure;
1133
+ }
1134
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
1135
+ if (response.status >= 200 && response.status < 300) {
1136
+ await drainResponseBody(response);
1137
+ return;
1138
+ }
1139
+ const text = await response.text();
1140
+ try {
1141
+ const health = JSON.parse(text);
1142
+ if (typeof health.startup_blocked === 'string')
1143
+ return;
1144
+ }
1145
+ catch {
1146
+ // The normal unavailable error below carries the response body.
1147
+ }
1148
+ throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
1149
+ },
1150
+ });
1151
+ }
1152
+ catch (error) {
1153
+ if (error === startupFailure)
1154
+ this.startupFailureReported = true;
1155
+ throw error;
1156
+ }
1157
+ }
1158
+ /** Optionally start the daemon, then observe availability before retrying the
1159
+ * request. Waiting is independent from permission to spawn: externally
1160
+ * managed daemons still get the same bounded readiness window. */
1161
+ async handleColdSocket(initialError, deadlineAt) {
1162
+ const startedHere = this.autostart && this.onColdSocket !== undefined && !this.coldStartAttempted;
1163
+ if (startedHere) {
1164
+ this.coldStartAttempted = true;
1165
+ await this.awaitWithinBudget(this.onColdSocket(), deadlineAt);
1166
+ }
1167
+ try {
1168
+ await this.awaitAvailability(initialError, deadlineAt);
1169
+ }
1170
+ catch (error) {
1171
+ if (startedHere && !this.startupFailureReported) {
1172
+ const diagnostic = safeColdStartDiagnostic(this.coldStartDiagnostic);
1173
+ if (diagnostic !== undefined) {
1174
+ const transportError = toTransportApiError(error);
1175
+ throw new ApiError(transportError.status, transportError.code, `${transportError.message}\n${diagnostic}`);
1176
+ }
1177
+ }
1178
+ throw error;
1179
+ }
1180
+ }
1181
+ }
1182
+ /** Append defined query params to a path. Kept in the client (not `routes.ts`,
1183
+ * which stays logic-free). Undefined/null values are skipped. */
1184
+ function withQuery(base, query) {
1185
+ if (query === undefined)
1186
+ return base;
1187
+ const params = new URLSearchParams();
1188
+ for (const [key, value] of Object.entries(query)) {
1189
+ if (value !== undefined && value !== null)
1190
+ params.set(key, String(value));
1191
+ }
1192
+ const qs = params.toString();
1193
+ return qs === '' ? base : `${base}?${qs}`;
1194
+ }
1195
+ /** Parse a response into `T` — the first (and here, only) read of its body —
1196
+ * or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
1197
+ * (callers that type `void`/optional handle it). */
1198
+ async function parse(res, deadlineAt) {
1199
+ const ok = res.status >= 200 && res.status < 300;
1200
+ const trimmed = (await readTextWithin(res, deadlineAt)).trim();
1201
+ let payload;
1202
+ if (trimmed !== '') {
1203
+ try {
1204
+ payload = JSON.parse(trimmed);
1205
+ }
1206
+ catch {
1207
+ if (ok)
1208
+ return undefined;
1209
+ throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
1210
+ }
1211
+ }
1212
+ if (ok)
1213
+ return payload;
1214
+ if (isErrorBody(payload)) {
1215
+ throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers, payload.error);
1216
+ }
1217
+ throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
1218
+ }
1219
+ /** `429`/`5xx` are the only statuses the §7 retry policy replays. */
1220
+ function isRetryableStatus(status) {
1221
+ return status === 429 || status >= 500;
1222
+ }
1223
+ /** A response abandoned mid-retry must have its stream drained, or the
1224
+ * underlying connection (and, over a unix socket, the daemon's handler) is
1225
+ * held open by a reader that will never arrive. */
1226
+ async function drainResponseBody(res, deadlineAt) {
1227
+ const cancelled = (async () => {
1228
+ try {
1229
+ await res.body?.cancel();
1230
+ }
1231
+ catch {
1232
+ // Best-effort — the response is being discarded either way.
1233
+ }
1234
+ })();
1235
+ // A body whose cancel never settles must not hold the call past its deadline: stop waiting for it then.
1236
+ if (deadlineAt === undefined)
1237
+ return cancelled;
1238
+ let timer;
1239
+ try {
1240
+ await Promise.race([cancelled, new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, deadlineAt - Date.now())); })]);
1241
+ }
1242
+ finally {
1243
+ if (timer !== undefined)
1244
+ clearTimeout(timer);
1245
+ }
1246
+ }
1247
+ /** The response body as text, or `deadline_exceeded` once `deadlineAt` passes first (the body is then cancelled
1248
+ * without waiting for it). A transport that sends headers and then stalls cannot hold the call past its deadline. */
1249
+ async function readTextWithin(res, deadlineAt) {
1250
+ if (deadlineAt === undefined)
1251
+ return res.text();
1252
+ const remaining = deadlineAt - Date.now();
1253
+ const reading = res.text();
1254
+ reading.catch(() => { });
1255
+ if (remaining <= 0) {
1256
+ void res.body?.cancel().catch(() => { });
1257
+ throw deadlineExceeded(deadlineAt);
1258
+ }
1259
+ let timer;
1260
+ try {
1261
+ return await Promise.race([
1262
+ reading,
1263
+ new Promise((_resolve, reject) => { timer = setTimeout(() => { void res.body?.cancel().catch(() => { }); reject(deadlineExceeded(deadlineAt)); }, remaining); }),
1264
+ ]);
1265
+ }
1266
+ finally {
1267
+ if (timer !== undefined)
1268
+ clearTimeout(timer);
1269
+ }
1270
+ }
1271
+ /** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
1272
+ function retryDelayMs(attempt) {
1273
+ return Math.min(2_000, 250 * 2 ** (attempt - 1));
1274
+ }
1275
+ /** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
1276
+ * wraps a connection error as `TypeError('fetch failed', { cause })`; the
1277
+ * Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
1278
+ * shape so both transports classify identically here. */
1279
+ function transportErrorCode(err) {
1280
+ if (typeof err !== 'object' || err === null)
1281
+ return undefined;
1282
+ const direct = err.code;
1283
+ if (typeof direct === 'string')
1284
+ return direct;
1285
+ const cause = err.cause;
1286
+ if (typeof cause === 'object' && cause !== null) {
1287
+ const causeCode = cause.code;
1288
+ if (typeof causeCode === 'string')
1289
+ return causeCode;
1290
+ }
1291
+ return undefined;
1292
+ }
1293
+ /** True when a transport throw is the caller's OWN abort, as opposed to the
1294
+ * client's internal per-request timeout — an aborted request must never be
1295
+ * retried, whichever reason fired. */
1296
+ function isAbortError(err, signal) {
1297
+ if (signal?.aborted)
1298
+ return true;
1299
+ return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
1300
+ }
1301
+ /** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
1302
+ * connection refusal here means the daemon is unreachable and autostart could
1303
+ * not recover it. */
1304
+ function toTransportApiError(err, signal) {
1305
+ if (signal?.aborted) {
1306
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
1307
+ }
1308
+ if (err instanceof ApiError)
1309
+ return err;
1310
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
1311
+ return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
1312
+ }
1313
+ if (err instanceof DOMException && err.name === 'AbortError') {
1314
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
1315
+ }
1316
+ const code = transportErrorCode(err);
1317
+ const message = err instanceof Error ? err.message : String(err);
1318
+ if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
1319
+ return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
1320
+ }
1321
+ if (code === 'ETIMEDOUT') {
1322
+ // A per-request timeout against a REACHABLE daemon is not "crtrd not running"
1323
+ // (§8 reserves daemon_unavailable for unreachable/autostart-failed).
1324
+ return new ApiError(504, 'request_timeout', `crtrd request timed out: ${message}`);
1325
+ }
1326
+ return new ApiError(503, 'transport_error', message);
1327
+ }
1328
+ /** The `Retry-After` header as seconds (delta-seconds form only), else undefined. */
1329
+ function retryAfterHeaderS(response) {
1330
+ const seconds = Number(response.headers.get('retry-after'));
1331
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined;
1332
+ }
1333
+ /** One retry wait inside a call's deadline. A server-requested wait (`retryAfterS`) is honored only against a deadline:
1334
+ * when it would not finish before the deadline the call fails now with `deadline_exceeded` (`reason: 'retry_after'`)
1335
+ * instead of sleeping past it; a call without a deadline keeps the plain backoff. The sleep ends early, with the same
1336
+ * error, if the caller aborts. */
1337
+ export async function sleepWithin(backoffMs, opts, retryAfterS, cause) {
1338
+ const { deadlineAt, signal } = opts;
1339
+ if (signal?.aborted)
1340
+ throw new ApiError(0, 'request_aborted', 'request aborted by caller signal');
1341
+ const requestedMs = deadlineAt !== undefined && retryAfterS !== undefined ? retryAfterS * 1_000 : undefined;
1342
+ const waitMs = Math.max(backoffMs, requestedMs ?? 0);
1343
+ if (deadlineAt !== undefined && Date.now() + waitMs >= deadlineAt) {
1344
+ const error = deadlineExceeded(deadlineAt, requestedMs !== undefined && requestedMs >= backoffMs ? 'retry_after' : 'elapsed', requestedMs === undefined ? undefined : retryAfterS);
1345
+ if (cause !== undefined)
1346
+ Object.defineProperty(error, 'cause', { value: cause, configurable: true, writable: true });
1347
+ throw error;
1348
+ }
1349
+ if (signal === undefined)
1350
+ return sleepMs(waitMs);
1351
+ await new Promise((resolve, reject) => {
1352
+ const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(new ApiError(0, 'request_aborted', 'request aborted by caller signal')); };
1353
+ const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, waitMs);
1354
+ signal.addEventListener('abort', onAbort, { once: true });
1355
+ });
1356
+ }
1357
+ /** `pending`, or its rejection with the signal's reason once the signal aborts first. A transport that ignores its
1358
+ * signal (a custom `fetch`) therefore still ends at the call's deadline; a response that arrives after the abort is
1359
+ * discarded and its body cancelled. */
1360
+ function abortable(pending, signal) {
1361
+ return new Promise((resolve, reject) => {
1362
+ const onAbort = () => reject(signal.reason);
1363
+ if (signal.aborted)
1364
+ onAbort();
1365
+ else
1366
+ signal.addEventListener('abort', onAbort, { once: true });
1367
+ pending.then((response) => {
1368
+ signal.removeEventListener('abort', onAbort);
1369
+ if (signal.aborted)
1370
+ void response.body?.cancel().catch(() => { });
1371
+ else
1372
+ resolve(response);
1373
+ }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
1374
+ });
1375
+ }
1376
+ function sleepMs(ms) {
1377
+ return new Promise((resolve) => setTimeout(resolve, ms));
1378
+ }
1379
+ export function safeColdStartDiagnostic(hook) {
1380
+ if (hook === undefined)
1381
+ return undefined;
1382
+ try {
1383
+ return hook();
1384
+ }
1385
+ catch {
1386
+ return undefined;
1387
+ }
1388
+ }