@north-light/crouter-api 0.3.320 → 0.3.322

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.
@@ -6,6 +6,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { CrtrClient, safeColdStartDiagnostic } from '../../client.js';
9
+ import { localClient } from '../../node-transport.js';
9
10
  import { ApiError } from '../../errors.js';
10
11
  function startDelayedHealthzServer(socketPath, delayMs) {
11
12
  const server = createServer((_req, res) => {
@@ -38,7 +39,7 @@ test('a hang-up mid-request rides out the daemon handover and replays the idempo
38
39
  const { server, ready } = startHandoverServer(socketPath);
39
40
  try {
40
41
  await ready;
41
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
42
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
42
43
  assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
43
44
  }
44
45
  finally {
@@ -52,7 +53,7 @@ test('an interrupted mutation is not replayed or labelled a daemon handover', as
52
53
  const { server, ready } = startHandoverServer(socketPath);
53
54
  try {
54
55
  await ready;
55
- const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
56
+ const client = localClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
56
57
  await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
57
58
  // Replaying is unsafe (the daemon may have applied it), so this one is the
58
59
  // caller's call — but it must not be reported as a daemon that is down.
@@ -68,7 +69,7 @@ test('a non-socket cold-start path surfaces its diagnostic after autostart times
68
69
  const socketPath = join(dir, 'crtrd.sock');
69
70
  mkdirSync(socketPath);
70
71
  const diagnostic = 'crtrd.log (tail):\napi.server.failed: EADDRINUSE';
71
- const client = new CrtrClient({
72
+ const client = localClient({
72
73
  socketPath,
73
74
  autostart: true,
74
75
  onColdSocket: async () => { },
@@ -89,7 +90,7 @@ test('cliClient-style cold start fails loud when the injected poll window is sho
89
90
  const socketPath = join(dir, 'crtrd.sock');
90
91
  const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
91
92
  try {
92
- const client = new CrtrClient({
93
+ const client = localClient({
93
94
  socketPath,
94
95
  autostart: true,
95
96
  onColdSocket: async () => {
@@ -111,7 +112,7 @@ test('a disabled-autostart client waits for an externally managed listener witho
111
112
  const { server, cancel } = startDelayedHealthzServer(socketPath, 100);
112
113
  let spawnAttempts = 0;
113
114
  try {
114
- const client = new CrtrClient({
115
+ const client = localClient({
115
116
  socketPath,
116
117
  autostart: false,
117
118
  onColdSocket: async () => { spawnAttempts += 1; },
@@ -132,7 +133,7 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
132
133
  const server = createServer((_req, _res) => { });
133
134
  let listenTimer;
134
135
  try {
135
- const client = new CrtrClient({
136
+ const client = localClient({
136
137
  socketPath,
137
138
  autostart: true,
138
139
  timeoutMs: 5_000,
@@ -150,6 +151,21 @@ test('availability expiry keeps the final transport failure inside one wall-cloc
150
151
  rmSync(dir, { recursive: true, force: true });
151
152
  }
152
153
  });
154
+ test('a caller abort with a custom reason is request_aborted', async () => {
155
+ const controller = new AbortController();
156
+ const client = new CrtrClient({
157
+ baseUrl: 'http://fixture.test',
158
+ fetch: async (_input, init) => await new Promise((_resolve, reject) => {
159
+ const signal = init?.signal;
160
+ if (signal === null || signal === undefined)
161
+ throw new Error('request signal is required');
162
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
163
+ }),
164
+ });
165
+ const request = client.request('GET', '/healthz', undefined, { signal: controller.signal });
166
+ controller.abort(new Error('caller custom abort'));
167
+ await assert.rejects(request, (error) => error instanceof ApiError && error.code === 'request_aborted');
168
+ });
153
169
  test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
154
170
  assert.equal(safeColdStartDiagnostic(undefined), undefined);
155
171
  });
@@ -14,9 +14,9 @@ import type { DeleteProfileRequest, DeleteProfileResultDTO, EnsureProfileRequest
14
14
  import type { FilePeekDTO } from './dto/files.js';
15
15
  import type { MemoryDocRefDTO } from './dto/memory.js';
16
16
  import type { ChatInventoryDTO, ProspectiveChatInventoryDTO, ProspectiveChatInventoryQuery } from './dto/chat-inventory.js';
17
- import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO } from './dto/modelauth.js';
17
+ import type { CredentialRemovalResultDTO, CredentialResultDTO, InstallCredentialRequest, ModelAuthListDTO, ModelAuthReadinessDTO, ModelAuthReadinessQuery } from './dto/modelauth.js';
18
18
  import type { CancelReviewRequest, CreateReviewRequest, ListReviewsQuery, ReviewCancelResultDTO, ReviewDocumentBaseDTO, ReviewDTO, ReviewListDTO, ReviewSubmitResultDTO } from './dto/reviews.js';
19
- import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
19
+ import type { CreateReviewCommentRequest, EditReviewCommentRequest, ListReviewCommentsQuery, ReadReviewCommentEventsQuery, ReviewCommentActionRequest, ReviewCommentDetailDTO, ReviewCommentEventsDTO, ReviewCommentForkDTO, ReviewCommentListDTO, ReviewCommentMutationDTO, ReviewCommentRangeBatchRequest, ReviewCommentRangeBatchResultDTO } from './dto/review-comments.js';
20
20
  import type { CancelInboxTicketRequest, CanceledTicketResultDTO, InboxListDTO, InboxPageDTO, InboxPageHistoryDTO, InboxPageResponseDTO, InboxTicketIdDTO, PageFeedbackResolutionDTO, PageResponsesDTO, PageTicketResultDTO, RespondInboxPageRequest } from './dto/inbox.js';
21
21
  import type { CreateHumanRequestDTO, CreateHumanRequestRequest, HumanRequestDTO, HumanRequestIdDTO, ReplaceHumanRequestRequest, RespondHumanRequestRequest, SettleHumanRequestRequest } from './dto/human-requests.js';
22
22
  import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
@@ -25,16 +25,18 @@ import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNa
25
25
  import type { AcknowledgeMailRequest, AcknowledgeMailResultDTO, ClaimMailRequest, ClaimMailResultDTO } from './dto/mail.js';
26
26
  import type { BrokerFaultInputDTO, BrokerFaultRequest, BrokerFaultResultDTO, BrokerProviderRetryRequest, BrokerProviderRetryResultDTO, BrokerTurnRequest, BrokerTurnResultDTO, NodeFaultClearResultDTO, RecoveryStateDTO } from './dto/recovery.js';
27
27
  export interface CrtrClientOptions {
28
- /** Unix socket path (default local transport). Exactly one of socketPath|baseUrl. */
29
- socketPath?: string;
30
- /** `http(s)://host:port` for TCP/remote transport. */
31
- baseUrl?: string;
28
+ /** `http(s)://host:port` for a TCP/remote transport. */
29
+ baseUrl: string;
30
+ /** Fetch implementation. The global Web fetch is used unless a caller supplies one. */
31
+ fetch?: typeof fetch;
32
32
  /** Extra headers, e.g. `{ authorization: 'Bearer <token>' }` when the target
33
33
  * crtrd's TCP listener has `CRTRD_TOKEN` set (unix-socket transport is
34
34
  * never checked, and a TCP daemon with no token set ignores this too). */
35
35
  headers?: Record<string, string>;
36
- /** Autostart on a cold socket (default true for socketPath, false for baseUrl). */
36
+ /** Retry a cold local connection through this hook once when enabled. */
37
37
  autostart?: boolean;
38
+ /** Maximum retries for transient GET, HEAD, and DELETE requests. Defaults to 2. */
39
+ maxRetries?: number;
38
40
  /** Per-request timeout in ms (default 30_000). */
39
41
  timeoutMs?: number;
40
42
  /** Injected daemon-start hook (spec §7.1). Called once on a cold socket when
@@ -64,22 +66,26 @@ export declare function waitForDaemonAvailability({ windowMs, probe, initialErro
64
66
  now?: () => number;
65
67
  sleep?: (ms: number) => Promise<void> | void;
66
68
  }): Promise<void>;
69
+ export interface CrtrRequestOptions {
70
+ headers?: Record<string, string>;
71
+ signal?: AbortSignal;
72
+ timeout?: number;
73
+ maxRetries?: number;
74
+ }
67
75
  export declare class CrtrClient {
68
- private readonly socketPath?;
69
- private readonly baseUrl?;
76
+ private readonly baseUrl;
77
+ private readonly fetch;
70
78
  private readonly headers;
71
79
  private readonly autostart;
72
80
  private readonly timeoutMs;
81
+ private readonly maxRetries;
82
+ private readonly localSocketTransport;
73
83
  private readonly onColdSocket?;
74
84
  private readonly coldStartDiagnostic?;
75
85
  private readonly coldStartPollWindowMs;
76
86
  /** Guards against invoking the daemon-start hook more than once per client. */
77
87
  private coldStartAttempted;
78
88
  constructor(opts: CrtrClientOptions);
79
- /** Construct a client bound to the default local socket with autostart on. Pass
80
- * `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
81
- * socket fails loud with `daemon_unavailable`. */
82
- static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
83
89
  healthz(): Promise<HealthDTO>;
84
90
  /** One `/healthz` observation without cold-socket recovery. Availability
85
91
  * waiters own retry policy and pass their remaining wall-clock budget here. */
@@ -219,6 +225,7 @@ export declare class CrtrClient {
219
225
  /** Force-delete or detach one profile by exact id or unique name. */
220
226
  deleteProfile(name: string, req: DeleteProfileRequest): Promise<DeleteProfileResultDTO>;
221
227
  listModelAuth(): Promise<ModelAuthListDTO>;
228
+ getModelAuthReadiness(query?: ModelAuthReadinessQuery): Promise<ModelAuthReadinessDTO>;
222
229
  installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
223
230
  removeCredential(provider: string): Promise<CredentialRemovalResultDTO>;
224
231
  createReview(req: CreateReviewRequest): Promise<ReviewDTO>;
@@ -236,6 +243,7 @@ export declare class CrtrClient {
236
243
  resolveReviewComment(commentId: string, req?: ReviewCommentActionRequest): Promise<ReviewCommentMutationDTO>;
237
244
  reopenReviewComment(commentId: string, req?: ReviewCommentActionRequest): Promise<ReviewCommentMutationDTO>;
238
245
  deleteReviewComment(commentId: string, req?: ReviewCommentActionRequest): Promise<ReviewCommentMutationDTO>;
246
+ forkReviewComment(commentId: string): Promise<ReviewCommentForkDTO>;
239
247
  /** Pending page/review tickets across every available crouter-owned
240
248
  * humanloop root. */
241
249
  listHumanInbox(): Promise<InboxListDTO>;
@@ -309,10 +317,12 @@ export declare class CrtrClient {
309
317
  canvasRoster(): Promise<RosterDTO>;
310
318
  prune(req: PruneRequest): Promise<PruneResultDTO>;
311
319
  /** Raw request for routes not yet method-wrapped. Applies the same
312
- * autostart + error-mapping semantics. */
313
- request<T>(method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
314
- /** The unparsed request: cold-socket and interrupted-response recovery, no
315
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
320
+ * autostart + retry + error-mapping semantics. */
321
+ request<T>(method: string, path: string, body?: unknown, opts?: CrtrRequestOptions): Promise<T>;
322
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
323
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
324
+ * only — POST/PATCH are never replayed once a request has actually been
325
+ * sent). No JSON parse; every wrapper goes through here. */
316
326
  private send;
317
327
  private nodePath;
318
328
  /** Validate a background job id before route construction. Job ids arrive
@@ -342,7 +352,11 @@ export declare class CrtrClient {
342
352
  * agent argv, so a bad one is a plausible request the server would also
343
353
  * reject, not a caller-bug `TypeError` like `ticketId`. */
344
354
  private commentPath;
355
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
356
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
357
+ * that wants to stream (SSE, later) never waits on a buffered body. */
345
358
  private transport;
359
+ private retainRequestLifetime;
346
360
  private isColdSocketError;
347
361
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
348
362
  * pipe). It establishes only that the response was interrupted, not why. */
@@ -1,40 +1,14 @@
1
- // CrtrClient — the typed HTTP+WS client over crtrd's API (spec §3.4).
1
+ // CrtrClient — the typed fetch client over crtrd's API.
2
2
  //
3
- // PURITY (spec §3.1): the ONLY runtime imports are Node built-ins
4
- // (`node:http`, `node:https`, `node:os`, `node:path`) plus `src/api/*`. Never
5
- // `core/*` / `node:sqlite` / TUI. The daemon-spawn logic lives in `core`/
6
- // `daemon`, which this module may not import — so autostart is delegated to an
7
- // injectable `onColdSocket` hook the CLI wires in (spec §7.1). A cold socket
8
- // always gets one bounded availability observation; the hook only permits spawning.
9
- //
10
- // TRANSPORT (spec O-1): `node:http`/`node:https` `request()` — NO `undici`.
11
- // unix socket via `{ socketPath }`; TCP/remote via a parsed `baseUrl`.
12
- import { envHomeOverride } from '../shared/env.js';
13
- import { request as httpRequest } from 'node:http';
14
- import { request as httpsRequest } from 'node:https';
15
- import { homedir } from 'node:os';
16
- import { join } from 'node:path';
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`.
17
5
  import { ApiError, isErrorBody } from './errors.js';
18
6
  import { routes } from './routes.js';
19
7
  import { isSafeNodeId } from './dto/common.js';
20
8
  import { isSafeBashJobId } from './dto/bash-jobs.js';
21
9
  import { isSafeCronId, } from './dto/crons.js';
22
- /** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
23
- * and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
24
- * imported — to keep the exported `/api` contract dependency-light; the two
25
- * MUST agree on the resolved socket path. */
26
- const CRTR_DIR_NAME = '.crouter';
27
- const SOCKET_BASENAME = 'crtrd.sock';
28
- /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
29
- * via Node built-ins only (no `core/canvas/paths.ts` import). */
30
- function defaultSocketPath() {
31
- const override = envHomeOverride();
32
- const home = override !== undefined && override !== ''
33
- ? override
34
- : join(homedir(), CRTR_DIR_NAME, 'canvas');
35
- return join(home, SOCKET_BASENAME);
36
- }
37
10
  const DEFAULT_TIMEOUT_MS = 30_000;
11
+ const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
38
12
  /** Default bounded window to wait for `/healthz` to come up after
39
13
  * `onColdSocket`, when the caller does not pass `coldStartPollWindowMs`. */
40
14
  const HEALTHZ_POLL_WINDOW_MS = 10_000;
@@ -64,41 +38,34 @@ export async function waitForDaemonAvailability({ windowMs, probe, initialError,
64
38
  }
65
39
  }
66
40
  export class CrtrClient {
67
- socketPath;
68
41
  baseUrl;
42
+ fetch;
69
43
  headers;
70
44
  autostart;
71
45
  timeoutMs;
46
+ maxRetries;
47
+ localSocketTransport;
72
48
  onColdSocket;
73
49
  coldStartDiagnostic;
74
50
  coldStartPollWindowMs;
75
51
  /** Guards against invoking the daemon-start hook more than once per client. */
76
52
  coldStartAttempted = false;
77
53
  constructor(opts) {
78
- const hasSocket = opts.socketPath !== undefined && opts.socketPath !== '';
79
- const hasBaseUrl = opts.baseUrl !== undefined && opts.baseUrl !== '';
80
- if (hasSocket === hasBaseUrl) {
81
- throw new TypeError('CrtrClient requires exactly one of socketPath | baseUrl');
82
- }
83
- if (hasSocket)
84
- this.socketPath = opts.socketPath;
85
- if (hasBaseUrl)
86
- this.baseUrl = new URL(opts.baseUrl);
54
+ if (opts.baseUrl === '')
55
+ throw new TypeError('CrtrClient requires baseUrl');
56
+ this.baseUrl = new URL(opts.baseUrl);
57
+ this.fetch = opts.fetch ?? globalThis.fetch;
87
58
  this.headers = { ...opts.headers };
88
- this.autostart = opts.autostart ?? hasSocket;
59
+ this.autostart = opts.autostart ?? false;
89
60
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
61
+ this.maxRetries = opts.maxRetries ?? 2;
62
+ this.localSocketTransport = this.fetch[LOCAL_SOCKET_FETCH] === true;
90
63
  if (opts.onColdSocket !== undefined)
91
64
  this.onColdSocket = opts.onColdSocket;
92
65
  if (opts.coldStartDiagnostic !== undefined)
93
66
  this.coldStartDiagnostic = opts.coldStartDiagnostic;
94
67
  this.coldStartPollWindowMs = opts.coldStartPollWindowMs ?? HEALTHZ_POLL_WINDOW_MS;
95
68
  }
96
- /** Construct a client bound to the default local socket with autostart on. Pass
97
- * `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
98
- * socket fails loud with `daemon_unavailable`. */
99
- static forLocalSocket(opts) {
100
- return new CrtrClient({ socketPath: defaultSocketPath(), autostart: true, ...opts });
101
- }
102
69
  // Health / status
103
70
  healthz() {
104
71
  return this.request('GET', routes.healthz());
@@ -106,13 +73,13 @@ export class CrtrClient {
106
73
  /** One `/healthz` observation without cold-socket recovery. Availability
107
74
  * waiters own retry policy and pass their remaining wall-clock budget here. */
108
75
  async probeHealthz(timeoutMs) {
109
- const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
76
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
110
77
  // A restricted daemon is alive and deliberately answers its health DTO with
111
78
  // 503. Preserve that state for daemon startup management instead of treating
112
79
  // the expected non-2xx status as a generic API failure.
113
80
  if (response.status === 503) {
114
81
  try {
115
- const health = JSON.parse(response.text);
82
+ const health = await response.clone().json();
116
83
  if (typeof health.startup_blocked === 'string')
117
84
  return health;
118
85
  }
@@ -156,7 +123,7 @@ export class CrtrClient {
156
123
  if (waitSeconds !== undefined && (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 25)) {
157
124
  throw new RangeError('waitSeconds must be an integer between 0 and 25');
158
125
  }
159
- return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, (waitSeconds ?? 0) * 1_000 + this.timeoutMs);
126
+ return this.request('GET', withQuery(routes.nodeOutcome(this.nodePath(id)), { wait: waitSeconds }), undefined, { timeout: (waitSeconds ?? 0) * 1_000 + this.timeoutMs });
160
127
  }
161
128
  /** Register or replace an armed target for terminal-outcome delivery. */
162
129
  registerOutcomeDelivery(id, req) {
@@ -438,6 +405,9 @@ export class CrtrClient {
438
405
  listModelAuth() {
439
406
  return this.request('GET', routes.modelAuths());
440
407
  }
408
+ getModelAuthReadiness(query) {
409
+ return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
410
+ }
441
411
  installCredential(provider, req) {
442
412
  return this.request('PUT', routes.modelAuth(provider), req);
443
413
  }
@@ -492,6 +462,9 @@ export class CrtrClient {
492
462
  deleteReviewComment(commentId, req = {}) {
493
463
  return this.request('POST', routes.humanCommentDelete(this.commentPath(commentId)), req);
494
464
  }
465
+ forkReviewComment(commentId) {
466
+ return this.request('POST', routes.humanCommentFork(this.commentPath(commentId)), {});
467
+ }
495
468
  // ---- Humanloop inbox (Northlight crouter-inbox v1, inbox-contract.md §A) --
496
469
  /** Pending page/review tickets across every available crouter-owned
497
470
  * humanloop root. */
@@ -629,29 +602,54 @@ export class CrtrClient {
629
602
  }
630
603
  // Escape hatch
631
604
  /** Raw request for routes not yet method-wrapped. Applies the same
632
- * autostart + error-mapping semantics. */
633
- async request(method, path, body, timeoutMs) {
634
- return parse(await this.send(method, path, body, undefined, timeoutMs));
635
- }
636
- /** The unparsed request: cold-socket and interrupted-response recovery, no
637
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
638
- async send(method, path, body, extraHeaders, timeoutMs) {
605
+ * autostart + retry + error-mapping semantics. */
606
+ async request(method, path, body, opts) {
639
607
  try {
640
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
608
+ return await parse(await this.send(method, path, body, opts));
641
609
  }
642
- catch (err) {
643
- if (this.isColdSocketError(err)) {
644
- try {
645
- await this.handleColdSocket(err);
646
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
610
+ catch (error) {
611
+ throw toTransportApiError(error, opts?.signal);
612
+ }
613
+ }
614
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
615
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
616
+ * only — POST/PATCH are never replayed once a request has actually been
617
+ * sent). No JSON parse; every wrapper goes through here. */
618
+ async send(method, path, body, opts = {}) {
619
+ const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
620
+ const maxRetries = opts.maxRetries ?? this.maxRetries;
621
+ for (let attempt = 0;; attempt++) {
622
+ let response;
623
+ try {
624
+ response = await this.transport(method, path, body, opts);
625
+ }
626
+ catch (err) {
627
+ if (this.isColdSocketError(err)) {
628
+ try {
629
+ await this.handleColdSocket(err);
630
+ response = await this.transport(method, path, body, opts);
631
+ }
632
+ catch (recoveryError) {
633
+ throw toTransportApiError(recoveryError, opts.signal);
634
+ }
635
+ }
636
+ else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
637
+ return await this.rideOutInterruptedRequest(method, path, body, opts);
647
638
  }
648
- catch (recoveryError) {
649
- throw toTransportApiError(recoveryError);
639
+ else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
640
+ await sleepMs(retryDelayMs(attempt + 1));
641
+ continue;
650
642
  }
643
+ else {
644
+ throw toTransportApiError(err, opts.signal);
645
+ }
646
+ }
647
+ if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
648
+ await drainResponseBody(response);
649
+ await sleepMs(retryDelayMs(attempt + 1));
650
+ continue;
651
651
  }
652
- if (this.isInterruptedSocketError(err))
653
- return await this.rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs);
654
- throw toTransportApiError(err);
652
+ return response;
655
653
  }
656
654
  }
657
655
  // internals
@@ -718,85 +716,103 @@ export class CrtrClient {
718
716
  }
719
717
  return id;
720
718
  }
721
- transport(method, path, body, extraHeaders, timeoutMs = this.timeoutMs) {
722
- const usingHttps = this.baseUrl?.protocol === 'https:';
723
- const doRequest = usingHttps ? httpsRequest : httpRequest;
719
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
720
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
721
+ * that wants to stream (SSE, later) never waits on a buffered body. */
722
+ transport(method, path, body, opts = {}) {
724
723
  const payload = body === undefined ? undefined : JSON.stringify(body);
725
- const headers = { accept: 'application/json', ...this.headers, ...extraHeaders };
726
- if (payload !== undefined) {
724
+ const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
725
+ if (payload !== undefined)
727
726
  headers['content-type'] = 'application/json';
728
- headers['content-length'] = String(Buffer.byteLength(payload));
727
+ const timeoutMs = opts.timeout ?? this.timeoutMs;
728
+ const controller = new AbortController();
729
+ const timer = timeoutMs > 0
730
+ ? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
731
+ : undefined;
732
+ const externalSignal = opts.signal;
733
+ const onExternalAbort = () => controller.abort(externalSignal?.reason);
734
+ if (externalSignal !== undefined) {
735
+ if (externalSignal.aborted)
736
+ controller.abort(externalSignal.reason);
737
+ else
738
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
729
739
  }
730
- const options = {
731
- method,
732
- path,
733
- headers,
734
- timeout: timeoutMs,
740
+ const finish = () => {
741
+ if (timer !== undefined)
742
+ clearTimeout(timer);
743
+ externalSignal?.removeEventListener('abort', onExternalAbort);
735
744
  };
736
- if (this.socketPath !== undefined) {
737
- options.socketPath = this.socketPath;
738
- }
739
- else if (this.baseUrl !== undefined) {
740
- options.protocol = this.baseUrl.protocol;
741
- options.hostname = this.baseUrl.hostname;
742
- if (this.baseUrl.port !== '')
743
- options.port = this.baseUrl.port;
745
+ return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
746
+ .then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
747
+ finish();
748
+ throw error;
749
+ });
750
+ }
751
+ retainRequestLifetime(response, finish, abortReason) {
752
+ if (response.body === null) {
753
+ finish();
754
+ return response;
744
755
  }
745
- return new Promise((resolve, reject) => {
746
- const req = doRequest(options, (res) => {
747
- const chunks = [];
748
- res.on('data', (chunk) => chunks.push(chunk));
749
- res.on('end', () => {
750
- const received = {};
751
- for (const [name, value] of Object.entries(res.headers)) {
752
- if (value !== undefined)
753
- received[name] = Array.isArray(value) ? value.join(', ') : value;
756
+ const reader = response.body.getReader();
757
+ let finished = false;
758
+ const close = () => {
759
+ if (finished)
760
+ return;
761
+ finished = true;
762
+ finish();
763
+ };
764
+ const body = new ReadableStream({
765
+ async pull(controller) {
766
+ try {
767
+ const { done, value } = await reader.read();
768
+ if (done) {
769
+ close();
770
+ controller.close();
754
771
  }
755
- resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8'), headers: received });
756
- });
757
- res.on('error', reject);
758
- });
759
- req.on('error', reject);
760
- req.on('timeout', () => {
761
- req.destroy(Object.assign(new Error('request timed out'), { code: 'ETIMEDOUT' }));
762
- });
763
- if (payload !== undefined)
764
- req.write(payload);
765
- req.end();
772
+ else {
773
+ controller.enqueue(value);
774
+ }
775
+ }
776
+ catch (error) {
777
+ close();
778
+ controller.error(abortReason() ?? error);
779
+ }
780
+ },
781
+ async cancel(reason) {
782
+ close();
783
+ await reader.cancel(reason);
784
+ },
766
785
  });
786
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
767
787
  }
768
788
  isColdSocketError(err) {
769
- if (this.socketPath === undefined)
770
- return false;
771
- const code = err?.code;
789
+ const code = transportErrorCode(err);
772
790
  return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
773
791
  }
774
792
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
775
793
  * pipe). It establishes only that the response was interrupted, not why. */
776
794
  isInterruptedSocketError(err) {
777
- if (this.socketPath === undefined)
778
- return false;
779
- const code = err?.code;
795
+ const code = transportErrorCode(err);
780
796
  return code === 'ECONNRESET' || code === 'EPIPE';
781
797
  }
782
798
  /** Wait for the local API after an interrupted response. GET/HEAD can retry
783
799
  * because they are idempotent. A mutation may already have been applied, so
784
800
  * it never replays. */
785
- async rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs) {
801
+ async rideOutInterruptedRequest(method, path, body, opts = {}) {
786
802
  try {
787
803
  await this.awaitAvailability();
788
804
  }
789
805
  catch (error) {
790
- throw toTransportApiError(error);
806
+ throw toTransportApiError(error, opts.signal);
791
807
  }
792
808
  if (method !== 'GET' && method !== 'HEAD') {
793
809
  throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
794
810
  }
795
811
  try {
796
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
812
+ return await this.transport(method, path, body, opts);
797
813
  }
798
814
  catch (err) {
799
- throw toTransportApiError(err);
815
+ throw toTransportApiError(err, opts.signal);
800
816
  }
801
817
  }
802
818
  async awaitAvailability(initialError) {
@@ -804,18 +820,19 @@ export class CrtrClient {
804
820
  windowMs: this.coldStartPollWindowMs,
805
821
  initialError,
806
822
  probe: async (timeoutMs) => {
807
- const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
823
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
808
824
  if (response.status >= 200 && response.status < 300)
809
825
  return;
826
+ const text = await response.text();
810
827
  try {
811
- const health = JSON.parse(response.text);
828
+ const health = JSON.parse(text);
812
829
  if (typeof health.startup_blocked === 'string')
813
830
  return;
814
831
  }
815
832
  catch {
816
833
  // The normal unavailable error below carries the response body.
817
834
  }
818
- throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${response.text.slice(0, 500)}`);
835
+ throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
819
836
  },
820
837
  });
821
838
  }
@@ -856,11 +873,12 @@ function withQuery(base, query) {
856
873
  const qs = params.toString();
857
874
  return qs === '' ? base : `${base}?${qs}`;
858
875
  }
859
- /** Parse a raw response into `T`, or throw `ApiError` on non-2xx. A 204/empty
860
- * body yields `undefined` (callers that type `void`/optional handle it). */
861
- function parse(res) {
876
+ /** Parse a response into `T` the first (and here, only) read of its body —
877
+ * or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
878
+ * (callers that type `void`/optional handle it). */
879
+ async function parse(res) {
862
880
  const ok = res.status >= 200 && res.status < 300;
863
- const trimmed = res.text.trim();
881
+ const trimmed = (await res.text()).trim();
864
882
  let payload;
865
883
  if (trimmed !== '') {
866
884
  try {
@@ -869,23 +887,77 @@ function parse(res) {
869
887
  catch {
870
888
  if (ok)
871
889
  return undefined;
872
- throw new ApiError(res.status, 'invalid_response', res.text.slice(0, 500));
890
+ throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
873
891
  }
874
892
  }
875
893
  if (ok)
876
894
  return payload;
877
895
  if (isErrorBody(payload)) {
878
- throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details);
896
+ throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers);
879
897
  }
880
- throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`);
898
+ throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
899
+ }
900
+ /** `429`/`5xx` are the only statuses the §7 retry policy replays. */
901
+ function isRetryableStatus(status) {
902
+ return status === 429 || status >= 500;
903
+ }
904
+ /** A response abandoned mid-retry must have its stream drained, or the
905
+ * underlying connection (and, over a unix socket, the daemon's handler) is
906
+ * held open by a reader that will never arrive. */
907
+ async function drainResponseBody(res) {
908
+ try {
909
+ await res.body?.cancel();
910
+ }
911
+ catch {
912
+ // Best-effort — the response is being discarded either way.
913
+ }
914
+ }
915
+ /** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
916
+ function retryDelayMs(attempt) {
917
+ return Math.min(2_000, 250 * 2 ** (attempt - 1));
918
+ }
919
+ /** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
920
+ * wraps a connection error as `TypeError('fetch failed', { cause })`; the
921
+ * Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
922
+ * shape so both transports classify identically here. */
923
+ function transportErrorCode(err) {
924
+ if (typeof err !== 'object' || err === null)
925
+ return undefined;
926
+ const direct = err.code;
927
+ if (typeof direct === 'string')
928
+ return direct;
929
+ const cause = err.cause;
930
+ if (typeof cause === 'object' && cause !== null) {
931
+ const causeCode = cause.code;
932
+ if (typeof causeCode === 'string')
933
+ return causeCode;
934
+ }
935
+ return undefined;
936
+ }
937
+ /** True when a transport throw is the caller's OWN abort, as opposed to the
938
+ * client's internal per-request timeout — an aborted request must never be
939
+ * retried, whichever reason fired. */
940
+ function isAbortError(err, signal) {
941
+ if (signal?.aborted)
942
+ return true;
943
+ return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
881
944
  }
882
945
  /** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
883
946
  * connection refusal here means the daemon is unreachable and autostart could
884
947
  * not recover it. */
885
- function toTransportApiError(err) {
948
+ function toTransportApiError(err, signal) {
949
+ if (signal?.aborted) {
950
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
951
+ }
886
952
  if (err instanceof ApiError)
887
953
  return err;
888
- const code = err?.code;
954
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
955
+ return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
956
+ }
957
+ if (err instanceof DOMException && err.name === 'AbortError') {
958
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
959
+ }
960
+ const code = transportErrorCode(err);
889
961
  const message = err instanceof Error ? err.message : String(err);
890
962
  if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
891
963
  return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
@@ -28,7 +28,6 @@ export interface BrokerSettleRequest extends BrokerExecutionRequest {
28
28
  stopReason: string;
29
29
  backgroundJobsRunning: boolean;
30
30
  pushedFinal: boolean;
31
- requestedHumanReply: boolean;
32
31
  }
33
32
  /** The only consequence a settle caller may enact. crtrd has already committed
34
33
  * every canvas and placement effect before returning this directive. */
@@ -66,6 +66,21 @@ export type ModelAuthProviderStatusDTO = {
66
66
  export interface ModelAuthListDTO {
67
67
  providers: ModelAuthProviderStatusDTO[];
68
68
  }
69
+ /** Inputs that select the exact provider a new root node would use. */
70
+ export interface ModelAuthReadinessQuery {
71
+ profile?: string;
72
+ cwd?: string;
73
+ kind?: string;
74
+ model?: string;
75
+ }
76
+ /** `GET /v1/model-auth/readiness` — credential state for the provider selected by a prospective root launch. */
77
+ export interface ModelAuthReadinessDTO {
78
+ provider: string;
79
+ model: string;
80
+ credential: 'ready' | 'missing' | 'unusable';
81
+ reason?: 'invalid_grant';
82
+ rate_limited_until?: string | null;
83
+ }
69
84
  /** `DELETE /v1/model-auth/{provider}` — all user credentials removed for one provider. */
70
85
  export interface CredentialRemovalResultDTO {
71
86
  provider: string;
@@ -1,6 +1,12 @@
1
1
  import type { IsoTime, NodeIdDTO, NodeStatusDTO, TerminalReasonDTO } from './common.js';
2
2
  import type { DeclinedResultDTO } from './reports.js';
3
3
  export type OutcomeKindDTO = 'result' | 'failure';
4
+ /** The terminal outcome fields carried by a node-list row. */
5
+ export interface NodeOutcomeSummaryDTO {
6
+ kind: OutcomeKindDTO;
7
+ reason: TerminalReasonDTO;
8
+ settled_at: IsoTime;
9
+ }
4
10
  /** Bounded diagnostics recorded for a failed node outcome. */
5
11
  export interface NodeOutcomeDetailV1 {
6
12
  schema: 'crtr.node-outcome-detail/v1';
@@ -1,6 +1,6 @@
1
1
  import type { Cursor, ExitIntentDTO, IsoTime, LifecycleDTO, ModeDTO, NodeIdDTO, NodeStatusDTO, TerminalReasonDTO } from './common.js';
2
2
  import type { ReportDTO } from './reports.js';
3
- import type { NodeOutcomeDTO, RegisterOutcomeDeliveryRequest } from './node-outcomes.js';
3
+ import type { NodeOutcomeDTO, NodeOutcomeSummaryDTO, RegisterOutcomeDeliveryRequest } from './node-outcomes.js';
4
4
  import type { FaultLink, FaultKind, FaultRetry, FaultProviderError } from './recovery.js';
5
5
  /** `GET /v1/nodes/{id}/subject` — the node-config subject substrate gate
6
6
  * predicates evaluate against. Mirrors `NodeConfigSubject`; this narrow
@@ -105,7 +105,7 @@ export interface NodeSummaryDTO {
105
105
  final_report: string | null;
106
106
  finalized_at: IsoTime | null;
107
107
  deadline_at: IsoTime | null;
108
- outcome: NodeOutcomeDTO | null;
108
+ outcome: NodeOutcomeSummaryDTO | null;
109
109
  fault?: NodeFaultDTO | null;
110
110
  streaming?: boolean;
111
111
  /** Present only when requested with `include=activity`. */
@@ -186,7 +186,8 @@ export interface NodeFaultDTO {
186
186
  }
187
187
  /** The full node view — summary ∪ identity extras ∪ edges ∪ paths. Returned by
188
188
  * `GET /v1/nodes/{id}` and by the create/lifecycle actions that yield a node. */
189
- export interface NodeDetailDTO extends NodeSummaryDTO {
189
+ export interface NodeDetailDTO extends Omit<NodeSummaryDTO, 'outcome'> {
190
+ outcome: NodeOutcomeDTO | null;
190
191
  /** Node that created this node, or null when an external process did. */
191
192
  creator: NodeIdDTO | null;
192
193
  /** The namer's prose form of `description` — sentence case, punctuation intact
@@ -136,3 +136,11 @@ export interface ReviewCommentRangeBatchResultDTO {
136
136
  detached: number;
137
137
  unchanged: number;
138
138
  }
139
+ /** Daemon-derived result of forking the review companion onto one comment. */
140
+ export interface ReviewCommentForkDTO {
141
+ comment_id: string;
142
+ /** The newborn fork of the review companion. */
143
+ node_id: NodeIdDTO;
144
+ /** Where the fork runs, so a caller can place a viewer for it. */
145
+ cwd: string;
146
+ }
@@ -9,14 +9,17 @@ export interface ErrorBody {
9
9
  /** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
10
10
  * failure such as an unreachable daemon). Carries the HTTP status, the stable
11
11
  * machine `code` from the `ErrorBody`, and optional structured `details`. */
12
- export declare class ApiError extends Error {
12
+ export declare class APIError extends Error {
13
13
  readonly status: number;
14
14
  readonly code: string;
15
15
  readonly details?: unknown;
16
- constructor(status: number, code: string, message: string, details?: unknown);
16
+ readonly headers: Headers;
17
+ constructor(status: number, code: string, message: string, details?: unknown, headers?: HeadersInit);
17
18
  }
19
+ /** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
20
+ export { APIError as ApiError };
18
21
  /** A local API transport failure has stable status/code identity, independent
19
22
  * of message prose. */
20
- export declare function isDaemonTransportApiError(error: unknown): error is ApiError;
23
+ export declare function isDaemonTransportApiError(error: unknown): error is APIError;
21
24
  /** Type guard for an `ErrorBody`-shaped parsed payload. */
22
25
  export declare function isErrorBody(value: unknown): value is ErrorBody;
@@ -5,23 +5,27 @@
5
5
  /** Thrown by `CrtrClient` on a non-2xx response (or a synthesized transport
6
6
  * failure such as an unreachable daemon). Carries the HTTP status, the stable
7
7
  * machine `code` from the `ErrorBody`, and optional structured `details`. */
8
- export class ApiError extends Error {
8
+ export class APIError extends Error {
9
9
  status;
10
10
  code;
11
11
  details;
12
- constructor(status, code, message, details) {
12
+ headers;
13
+ constructor(status, code, message, details, headers) {
13
14
  super(message);
14
- this.name = 'ApiError';
15
+ this.name = 'APIError';
15
16
  this.status = status;
16
17
  this.code = code;
18
+ this.headers = new Headers(headers);
17
19
  if (details !== undefined)
18
20
  this.details = details;
19
21
  }
20
22
  }
23
+ /** @deprecated Use APIError. Kept as the in-repo compatibility alias. */
24
+ export { APIError as ApiError };
21
25
  /** A local API transport failure has stable status/code identity, independent
22
26
  * of message prose. */
23
27
  export function isDaemonTransportApiError(error) {
24
- if (!(error instanceof ApiError))
28
+ if (!(error instanceof APIError))
25
29
  return false;
26
30
  return (error.status === 503 && (error.code === 'daemon_unavailable'
27
31
  || error.code === 'transport_error'
@@ -1,6 +1,6 @@
1
1
  export { CrtrClient, waitForDaemonAvailability } from './client.js';
2
- export type { CrtrClientOptions } from './client.js';
3
- export { ApiError, isErrorBody } from './errors.js';
2
+ export type { CrtrClientOptions, CrtrRequestOptions } from './client.js';
3
+ export { APIError, ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
5
5
  export { API_VERSION, routes } from './routes.js';
6
6
  export * from '../shared/generated-context.js';
package/dist/api/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // route constants, error contract, and the typed `CrtrClient`. Dependency-light
3
3
  // by design (spec §3.1): Node built-ins + `src/api/*` only.
4
4
  export { CrtrClient, waitForDaemonAvailability } from './client.js';
5
- export { ApiError, isErrorBody } from './errors.js';
5
+ export { APIError, ApiError, isErrorBody } from './errors.js';
6
6
  export { API_VERSION, routes } from './routes.js';
7
7
  export * from '../shared/generated-context.js';
8
8
  export * from './dto/common.js';
@@ -0,0 +1,18 @@
1
+ import { CrtrClient, type CrtrClientOptions } from './client.js';
2
+ /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
3
+ * via Node built-ins only (no `core/canvas/paths.ts` import). */
4
+ export declare function defaultSocketPath(): string;
5
+ /** A standard `fetch` bound to one unix socket, built on `node:http` with
6
+ * `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
7
+ * fed as bytes arrive — never buffered before the caller sees headers —
8
+ * which is what lets `CrtrClient` serve a streaming route (SSE) later without
9
+ * a second transport. Connection failures are wrapped as `TypeError('fetch
10
+ * failed', { cause })`, matching the shape native `fetch` throws, so
11
+ * `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
12
+ * reads identically off either transport. */
13
+ export declare function socketFetch(socketPath: string): typeof fetch;
14
+ /** Construct a client bound to the default local socket with autostart on.
15
+ * Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
16
+ export declare function localClient(opts?: Omit<CrtrClientOptions, 'baseUrl' | 'fetch'> & {
17
+ socketPath?: string;
18
+ }): CrtrClient;
@@ -0,0 +1,101 @@
1
+ // The Node-only half of `@north-light/crouter-api`: crtrd's default unix
2
+ // socket path, a `fetch` implementation that speaks it, and `localClient()` —
3
+ // the one-line way to get a `CrtrClient` bound to the owner's own daemon.
4
+ //
5
+ // This is a SEPARATE package entry (`@north-light/crouter-api/node`) from the
6
+ // root so a browser bundle that imports the root never resolves `node:http`,
7
+ // `node:os`, or `node:path`. Import from here, never re-export it off the root.
8
+ import { request as httpRequest } from 'node:http';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { CrtrClient } from './client.js';
12
+ import { envHomeOverride } from '../shared/env.js';
13
+ /** Filesystem/scope constants mirrored from `core/types.ts` (`CRTR_DIR_NAME`)
14
+ * and `core/canvas/paths.ts` (`crtrHome`/`apiSocketPath`). Duplicated — not
15
+ * imported — to keep the exported `/api` contract dependency-light; the two
16
+ * MUST agree on the resolved socket path. */
17
+ const CRTR_DIR_NAME = '.crouter';
18
+ const SOCKET_BASENAME = 'crtrd.sock';
19
+ const LOCAL_SOCKET_FETCH = Symbol.for('@north-light/crouter-api/local-socket-fetch');
20
+ /** Resolve crtrd's default unix socket path the same way `apiSocketPath()` does,
21
+ * via Node built-ins only (no `core/canvas/paths.ts` import). */
22
+ export function defaultSocketPath() {
23
+ const override = envHomeOverride();
24
+ const home = override !== undefined && override !== ''
25
+ ? override
26
+ : join(homedir(), CRTR_DIR_NAME, 'canvas');
27
+ return join(home, SOCKET_BASENAME);
28
+ }
29
+ /** A standard `fetch` bound to one unix socket, built on `node:http` with
30
+ * `{ socketPath }`. The returned `Response`'s body is a live `ReadableStream`
31
+ * fed as bytes arrive — never buffered before the caller sees headers —
32
+ * which is what lets `CrtrClient` serve a streaming route (SSE) later without
33
+ * a second transport. Connection failures are wrapped as `TypeError('fetch
34
+ * failed', { cause })`, matching the shape native `fetch` throws, so
35
+ * `CrtrClient`'s cold-socket/interrupted-request classification (`err.cause.code`)
36
+ * reads identically off either transport. */
37
+ export function socketFetch(socketPath) {
38
+ const fetchOverSocket = function (input, init = {}) {
39
+ const url = input instanceof URL ? input : new URL(typeof input === 'string' ? input : input.url);
40
+ const method = init.method ?? 'GET';
41
+ const headers = {};
42
+ if (init.headers !== undefined) {
43
+ for (const [name, value] of new Headers(init.headers).entries())
44
+ headers[name] = value;
45
+ }
46
+ const payload = typeof init.body === 'string' ? init.body : undefined;
47
+ if (payload !== undefined)
48
+ headers['content-length'] = String(Buffer.byteLength(payload));
49
+ return new Promise((resolvePromise, reject) => {
50
+ const req = httpRequest({ socketPath, path: url.pathname + url.search, method, headers }, (res) => {
51
+ const body = new ReadableStream({
52
+ start(controller) {
53
+ res.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
54
+ res.on('end', () => controller.close());
55
+ res.on('error', (err) => controller.error(err));
56
+ },
57
+ cancel() {
58
+ res.destroy();
59
+ },
60
+ });
61
+ const responseHeaders = new Headers();
62
+ for (const [name, value] of Object.entries(res.headers)) {
63
+ if (value === undefined)
64
+ continue;
65
+ for (const one of Array.isArray(value) ? value : [value])
66
+ responseHeaders.append(name, one);
67
+ }
68
+ resolvePromise(new Response(body, { status: res.statusCode ?? 0, statusText: res.statusMessage, headers: responseHeaders }));
69
+ });
70
+ req.on('error', (err) => reject(new TypeError('fetch failed', { cause: err })));
71
+ const signal = init.signal;
72
+ const abort = () => {
73
+ req.destroy();
74
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
75
+ };
76
+ if (signal !== null && signal !== undefined) {
77
+ if (signal.aborted) {
78
+ abort();
79
+ return;
80
+ }
81
+ signal.addEventListener('abort', abort, { once: true });
82
+ }
83
+ if (payload !== undefined)
84
+ req.write(payload);
85
+ req.end();
86
+ });
87
+ };
88
+ fetchOverSocket[LOCAL_SOCKET_FETCH] = true;
89
+ return fetchOverSocket;
90
+ }
91
+ /** Construct a client bound to the default local socket with autostart on.
92
+ * Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
93
+ export function localClient(opts = {}) {
94
+ const { socketPath, ...rest } = opts;
95
+ return new CrtrClient({
96
+ baseUrl: 'http://localhost',
97
+ fetch: socketFetch(socketPath ?? defaultSocketPath()),
98
+ autostart: true,
99
+ ...rest,
100
+ });
101
+ }
@@ -89,6 +89,7 @@ export declare const routes: {
89
89
  readonly humanCommentResolve: (commentId: string) => string;
90
90
  readonly humanCommentReopen: (commentId: string) => string;
91
91
  readonly humanCommentDelete: (commentId: string) => string;
92
+ readonly humanCommentFork: (commentId: string) => string;
92
93
  readonly humanInbox: () => string;
93
94
  readonly humanInboxTicket: (ticketId: string) => string;
94
95
  readonly humanInboxRespond: (ticketId: string) => string;
@@ -108,6 +109,7 @@ export declare const routes: {
108
109
  readonly profileResume: (name: string) => string;
109
110
  readonly profileMetadata: (name: string) => string;
110
111
  readonly modelAuths: () => string;
112
+ readonly modelAuthReadiness: () => string;
111
113
  readonly modelAuth: (provider: string) => string;
112
114
  readonly filePeek: () => string;
113
115
  readonly memoryResolve: () => string;
@@ -113,6 +113,7 @@ export const routes = {
113
113
  humanCommentResolve: (commentId) => `${V}/human/comments/${commentId}/resolve`,
114
114
  humanCommentReopen: (commentId) => `${V}/human/comments/${commentId}/reopen`,
115
115
  humanCommentDelete: (commentId) => `${V}/human/comments/${commentId}/delete`,
116
+ humanCommentFork: (commentId) => `${V}/human/comments/${commentId}/fork`,
116
117
  // Humanloop inbox (Northlight crouter-inbox v1, inbox-contract.md §A)
117
118
  humanInbox: () => `${V}/human/inbox`,
118
119
  humanInboxTicket: (ticketId) => `${V}/human/inbox/${ticketId}`,
@@ -137,6 +138,7 @@ export const routes = {
137
138
  profileMetadata: (name) => `${V}/profiles/${name}/metadata`,
138
139
  // Model auth
139
140
  modelAuths: () => `${V}/model-auth`,
141
+ modelAuthReadiness: () => `${V}/model-auth/readiness`,
140
142
  modelAuth: (provider) => `${V}/model-auth/${provider}`,
141
143
  // Host file read (browser file-peek panel). The absolute path rides as a
142
144
  // `path` query param, not a path segment — it is not a single safe segment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter-api",
3
- "version": "0.3.320",
3
+ "version": "0.3.322",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -12,6 +12,12 @@
12
12
  "require": "./dist/api/index.js",
13
13
  "default": "./dist/api/index.js"
14
14
  },
15
+ "./node": {
16
+ "types": "./dist/api/node-transport.d.ts",
17
+ "import": "./dist/api/node-transport.js",
18
+ "require": "./dist/api/node-transport.js",
19
+ "default": "./dist/api/node-transport.js"
20
+ },
15
21
  "./plugin-manifest": {
16
22
  "types": "./dist/api/plugin-manifest-schema.d.ts",
17
23
  "import": "./dist/api/plugin-manifest-schema.js",