@north-light/crouter-api 0.3.321 → 0.3.323

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,27 +14,29 @@ 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
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
- import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
22
+ import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryStatsQuery, HistoryStatsResultDTO, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, GraphDTO, PruneRequest, PruneResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
23
23
  import type { AbandonWorktreeRequest, AbandonWorktreeResultDTO, CloseWorktreeResultDTO, QuarantinedWorktreeDTO } from './dto/worktree.js';
24
- import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest } from './dto/broker-ops.js';
24
+ import type { BrokerExtensionStateDTO, BrokerExecutionRequest, BrokerGeneratedNameRequest, BrokerGeneratedNameResultDTO, BrokerModelCommitRequest, BrokerModelCommitResultDTO, BrokerParkActivityResultDTO, BrokerParkCompleteRequest, BrokerPersonaAckRequest, BrokerPersonaAckResultDTO, BrokerSessionBoundRequest, BrokerSessionBoundResultDTO, BrokerSettleDirective, BrokerSettleRequest, BrokerTelemetryRequest } from './dto/broker-ops.js';
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. */
@@ -123,6 +129,7 @@ export declare class CrtrClient {
123
129
  settleBroker(id: string, req: BrokerSettleRequest): Promise<BrokerSettleDirective>;
124
130
  completeBrokerPark(id: string, req: BrokerParkCompleteRequest): Promise<BrokerSettleDirective>;
125
131
  recordBrokerParkActivity(id: string, req: BrokerExecutionRequest): Promise<BrokerParkActivityResultDTO>;
132
+ recordBrokerTelemetry(id: string, req: BrokerTelemetryRequest): Promise<void>;
126
133
  claimNodeMail(id: string, req: ClaimMailRequest): Promise<ClaimMailResultDTO>;
127
134
  acknowledgeNodeMail(id: string, req: AcknowledgeMailRequest): Promise<AcknowledgeMailResultDTO>;
128
135
  recordBrokerTurn(id: string, req: BrokerTurnRequest): Promise<BrokerTurnResultDTO>;
@@ -219,6 +226,7 @@ export declare class CrtrClient {
219
226
  /** Force-delete or detach one profile by exact id or unique name. */
220
227
  deleteProfile(name: string, req: DeleteProfileRequest): Promise<DeleteProfileResultDTO>;
221
228
  listModelAuth(): Promise<ModelAuthListDTO>;
229
+ getModelAuthReadiness(query?: ModelAuthReadinessQuery): Promise<ModelAuthReadinessDTO>;
222
230
  installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
223
231
  removeCredential(provider: string): Promise<CredentialRemovalResultDTO>;
224
232
  createReview(req: CreateReviewRequest): Promise<ReviewDTO>;
@@ -308,12 +316,17 @@ export declare class CrtrClient {
308
316
  * poll target for attach/browser topology; use `canvasSnapshot` for the
309
317
  * enriched on-demand view. */
310
318
  canvasRoster(): Promise<RosterDTO>;
319
+ /** The lean attach-graph projection: display rows, topology, and focus state
320
+ * in one daemon-owned read rather than the full node-summary list. */
321
+ canvasGraph(): Promise<GraphDTO>;
311
322
  prune(req: PruneRequest): Promise<PruneResultDTO>;
312
323
  /** Raw request for routes not yet method-wrapped. Applies the same
313
- * autostart + error-mapping semantics. */
314
- request<T>(method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
315
- /** The unparsed request: cold-socket and interrupted-response recovery, no
316
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
324
+ * autostart + retry + error-mapping semantics. */
325
+ request<T>(method: string, path: string, body?: unknown, opts?: CrtrRequestOptions): Promise<T>;
326
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
327
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
328
+ * only — POST/PATCH are never replayed once a request has actually been
329
+ * sent). No JSON parse; every wrapper goes through here. */
317
330
  private send;
318
331
  private nodePath;
319
332
  /** Validate a background job id before route construction. Job ids arrive
@@ -343,7 +356,11 @@ export declare class CrtrClient {
343
356
  * agent argv, so a bad one is a plausible request the server would also
344
357
  * reject, not a caller-bug `TypeError` like `ticketId`. */
345
358
  private commentPath;
359
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
360
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
361
+ * that wants to stream (SSE, later) never waits on a buffered body. */
346
362
  private transport;
363
+ private retainRequestLifetime;
347
364
  private isColdSocketError;
348
365
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
349
366
  * 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) {
@@ -211,6 +178,9 @@ export class CrtrClient {
211
178
  recordBrokerParkActivity(id, req) {
212
179
  return this.request('POST', routes.nodeBrokerParkActivity(this.nodePath(id)), req);
213
180
  }
181
+ async recordBrokerTelemetry(id, req) {
182
+ await this.request('POST', routes.nodeBrokerTelemetry(this.nodePath(id)), req);
183
+ }
214
184
  claimNodeMail(id, req) {
215
185
  return this.request('POST', routes.nodeMailClaim(this.nodePath(id)), req);
216
186
  }
@@ -438,6 +408,9 @@ export class CrtrClient {
438
408
  listModelAuth() {
439
409
  return this.request('GET', routes.modelAuths());
440
410
  }
411
+ getModelAuthReadiness(query) {
412
+ return this.request('GET', withQuery(routes.modelAuthReadiness(), query));
413
+ }
441
414
  installCredential(provider, req) {
442
415
  return this.request('PUT', routes.modelAuth(provider), req);
443
416
  }
@@ -627,34 +600,64 @@ export class CrtrClient {
627
600
  canvasRoster() {
628
601
  return this.request('GET', routes.canvasRoster());
629
602
  }
603
+ /** The lean attach-graph projection: display rows, topology, and focus state
604
+ * in one daemon-owned read rather than the full node-summary list. */
605
+ canvasGraph() {
606
+ return this.request('GET', routes.canvasGraph());
607
+ }
630
608
  prune(req) {
631
609
  return this.request('POST', routes.canvasPrune(), req);
632
610
  }
633
611
  // Escape hatch
634
612
  /** Raw request for routes not yet method-wrapped. Applies the same
635
- * autostart + error-mapping semantics. */
636
- async request(method, path, body, timeoutMs) {
637
- return parse(await this.send(method, path, body, undefined, timeoutMs));
638
- }
639
- /** The unparsed request: cold-socket and interrupted-response recovery, no
640
- * JSON parse. Every wrapper goes through here; only non-JSON routes call it directly. */
641
- async send(method, path, body, extraHeaders, timeoutMs) {
613
+ * autostart + retry + error-mapping semantics. */
614
+ async request(method, path, body, opts) {
642
615
  try {
643
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
616
+ return await parse(await this.send(method, path, body, opts));
644
617
  }
645
- catch (err) {
646
- if (this.isColdSocketError(err)) {
647
- try {
648
- await this.handleColdSocket(err);
649
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
618
+ catch (error) {
619
+ throw toTransportApiError(error, opts?.signal);
620
+ }
621
+ }
622
+ /** The unparsed request: cold-socket and interrupted-response recovery, plus
623
+ * the §7 retry policy (connection errors and 429/5xx on GET/HEAD/DELETE
624
+ * only — POST/PATCH are never replayed once a request has actually been
625
+ * sent). No JSON parse; every wrapper goes through here. */
626
+ async send(method, path, body, opts = {}) {
627
+ const idempotent = method === 'GET' || method === 'HEAD' || method === 'DELETE';
628
+ const maxRetries = opts.maxRetries ?? this.maxRetries;
629
+ for (let attempt = 0;; attempt++) {
630
+ let response;
631
+ try {
632
+ response = await this.transport(method, path, body, opts);
633
+ }
634
+ catch (err) {
635
+ if (this.isColdSocketError(err)) {
636
+ try {
637
+ await this.handleColdSocket(err);
638
+ response = await this.transport(method, path, body, opts);
639
+ }
640
+ catch (recoveryError) {
641
+ throw toTransportApiError(recoveryError, opts.signal);
642
+ }
650
643
  }
651
- catch (recoveryError) {
652
- throw toTransportApiError(recoveryError);
644
+ else if (this.isInterruptedSocketError(err) && this.localSocketTransport && (!idempotent || attempt < maxRetries)) {
645
+ return await this.rideOutInterruptedRequest(method, path, body, opts);
653
646
  }
647
+ else if (idempotent && attempt < maxRetries && !isAbortError(err, opts.signal) && (!this.localSocketTransport || !this.isInterruptedSocketError(err))) {
648
+ await sleepMs(retryDelayMs(attempt + 1));
649
+ continue;
650
+ }
651
+ else {
652
+ throw toTransportApiError(err, opts.signal);
653
+ }
654
+ }
655
+ if (idempotent && attempt < maxRetries && isRetryableStatus(response.status)) {
656
+ await drainResponseBody(response);
657
+ await sleepMs(retryDelayMs(attempt + 1));
658
+ continue;
654
659
  }
655
- if (this.isInterruptedSocketError(err))
656
- return await this.rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs);
657
- throw toTransportApiError(err);
660
+ return response;
658
661
  }
659
662
  }
660
663
  // internals
@@ -721,85 +724,103 @@ export class CrtrClient {
721
724
  }
722
725
  return id;
723
726
  }
724
- transport(method, path, body, extraHeaders, timeoutMs = this.timeoutMs) {
725
- const usingHttps = this.baseUrl?.protocol === 'https:';
726
- const doRequest = usingHttps ? httpsRequest : httpRequest;
727
+ /** The one fetch path (§1). Resolves as soon as headers arrive — the body
728
+ * stays an unread `ReadableStream` on the returned `Response`, so a caller
729
+ * that wants to stream (SSE, later) never waits on a buffered body. */
730
+ transport(method, path, body, opts = {}) {
727
731
  const payload = body === undefined ? undefined : JSON.stringify(body);
728
- const headers = { accept: 'application/json', ...this.headers, ...extraHeaders };
729
- if (payload !== undefined) {
732
+ const headers = { accept: 'application/json', ...this.headers, ...opts.headers };
733
+ if (payload !== undefined)
730
734
  headers['content-type'] = 'application/json';
731
- headers['content-length'] = String(Buffer.byteLength(payload));
735
+ const timeoutMs = opts.timeout ?? this.timeoutMs;
736
+ const controller = new AbortController();
737
+ const timer = timeoutMs > 0
738
+ ? setTimeout(() => controller.abort(new DOMException('request timed out', 'TimeoutError')), timeoutMs)
739
+ : undefined;
740
+ const externalSignal = opts.signal;
741
+ const onExternalAbort = () => controller.abort(externalSignal?.reason);
742
+ if (externalSignal !== undefined) {
743
+ if (externalSignal.aborted)
744
+ controller.abort(externalSignal.reason);
745
+ else
746
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
732
747
  }
733
- const options = {
734
- method,
735
- path,
736
- headers,
737
- timeout: timeoutMs,
748
+ const finish = () => {
749
+ if (timer !== undefined)
750
+ clearTimeout(timer);
751
+ externalSignal?.removeEventListener('abort', onExternalAbort);
738
752
  };
739
- if (this.socketPath !== undefined) {
740
- options.socketPath = this.socketPath;
741
- }
742
- else if (this.baseUrl !== undefined) {
743
- options.protocol = this.baseUrl.protocol;
744
- options.hostname = this.baseUrl.hostname;
745
- if (this.baseUrl.port !== '')
746
- options.port = this.baseUrl.port;
753
+ return this.fetch(new URL(path, this.baseUrl), { method, headers, body: payload, signal: controller.signal })
754
+ .then((response) => this.retainRequestLifetime(response, finish, () => controller.signal.aborted ? controller.signal.reason : undefined), (error) => {
755
+ finish();
756
+ throw error;
757
+ });
758
+ }
759
+ retainRequestLifetime(response, finish, abortReason) {
760
+ if (response.body === null) {
761
+ finish();
762
+ return response;
747
763
  }
748
- return new Promise((resolve, reject) => {
749
- const req = doRequest(options, (res) => {
750
- const chunks = [];
751
- res.on('data', (chunk) => chunks.push(chunk));
752
- res.on('end', () => {
753
- const received = {};
754
- for (const [name, value] of Object.entries(res.headers)) {
755
- if (value !== undefined)
756
- received[name] = Array.isArray(value) ? value.join(', ') : value;
764
+ const reader = response.body.getReader();
765
+ let finished = false;
766
+ const close = () => {
767
+ if (finished)
768
+ return;
769
+ finished = true;
770
+ finish();
771
+ };
772
+ const body = new ReadableStream({
773
+ async pull(controller) {
774
+ try {
775
+ const { done, value } = await reader.read();
776
+ if (done) {
777
+ close();
778
+ controller.close();
779
+ }
780
+ else {
781
+ controller.enqueue(value);
757
782
  }
758
- resolve({ status: res.statusCode ?? 0, text: Buffer.concat(chunks).toString('utf8'), headers: received });
759
- });
760
- res.on('error', reject);
761
- });
762
- req.on('error', reject);
763
- req.on('timeout', () => {
764
- req.destroy(Object.assign(new Error('request timed out'), { code: 'ETIMEDOUT' }));
765
- });
766
- if (payload !== undefined)
767
- req.write(payload);
768
- req.end();
783
+ }
784
+ catch (error) {
785
+ close();
786
+ controller.error(abortReason() ?? error);
787
+ }
788
+ },
789
+ async cancel(reason) {
790
+ close();
791
+ await reader.cancel(reason);
792
+ },
769
793
  });
794
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
770
795
  }
771
796
  isColdSocketError(err) {
772
- if (this.socketPath === undefined)
773
- return false;
774
- const code = err?.code;
797
+ const code = transportErrorCode(err);
775
798
  return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK';
776
799
  }
777
800
  /** A connection torn down MID-request (Node's "socket hang up" / a broken
778
801
  * pipe). It establishes only that the response was interrupted, not why. */
779
802
  isInterruptedSocketError(err) {
780
- if (this.socketPath === undefined)
781
- return false;
782
- const code = err?.code;
803
+ const code = transportErrorCode(err);
783
804
  return code === 'ECONNRESET' || code === 'EPIPE';
784
805
  }
785
806
  /** Wait for the local API after an interrupted response. GET/HEAD can retry
786
807
  * because they are idempotent. A mutation may already have been applied, so
787
808
  * it never replays. */
788
- async rideOutInterruptedRequest(method, path, body, extraHeaders, timeoutMs) {
809
+ async rideOutInterruptedRequest(method, path, body, opts = {}) {
789
810
  try {
790
811
  await this.awaitAvailability();
791
812
  }
792
813
  catch (error) {
793
- throw toTransportApiError(error);
814
+ throw toTransportApiError(error, opts.signal);
794
815
  }
795
816
  if (method !== 'GET' && method !== 'HEAD') {
796
817
  throw new ApiError(503, 'daemon_request_interrupted', `crtrd connection ended before this ${method} response; the request may or may not have been applied.`);
797
818
  }
798
819
  try {
799
- return await this.transport(method, path, body, extraHeaders, timeoutMs);
820
+ return await this.transport(method, path, body, opts);
800
821
  }
801
822
  catch (err) {
802
- throw toTransportApiError(err);
823
+ throw toTransportApiError(err, opts.signal);
803
824
  }
804
825
  }
805
826
  async awaitAvailability(initialError) {
@@ -807,18 +828,19 @@ export class CrtrClient {
807
828
  windowMs: this.coldStartPollWindowMs,
808
829
  initialError,
809
830
  probe: async (timeoutMs) => {
810
- const response = await this.transport('GET', routes.healthz(), undefined, undefined, timeoutMs);
831
+ const response = await this.transport('GET', routes.healthz(), undefined, { timeout: timeoutMs });
811
832
  if (response.status >= 200 && response.status < 300)
812
833
  return;
834
+ const text = await response.text();
813
835
  try {
814
- const health = JSON.parse(response.text);
836
+ const health = JSON.parse(text);
815
837
  if (typeof health.startup_blocked === 'string')
816
838
  return;
817
839
  }
818
840
  catch {
819
841
  // The normal unavailable error below carries the response body.
820
842
  }
821
- throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${response.text.slice(0, 500)}`);
843
+ throw new ApiError(response.status, 'daemon_health_unavailable', `crtrd health check returned HTTP ${response.status}: ${text.slice(0, 500)}`);
822
844
  },
823
845
  });
824
846
  }
@@ -859,11 +881,12 @@ function withQuery(base, query) {
859
881
  const qs = params.toString();
860
882
  return qs === '' ? base : `${base}?${qs}`;
861
883
  }
862
- /** Parse a raw response into `T`, or throw `ApiError` on non-2xx. A 204/empty
863
- * body yields `undefined` (callers that type `void`/optional handle it). */
864
- function parse(res) {
884
+ /** Parse a response into `T` the first (and here, only) read of its body —
885
+ * or throw `ApiError` on non-2xx. A 204/empty body yields `undefined`
886
+ * (callers that type `void`/optional handle it). */
887
+ async function parse(res) {
865
888
  const ok = res.status >= 200 && res.status < 300;
866
- const trimmed = res.text.trim();
889
+ const trimmed = (await res.text()).trim();
867
890
  let payload;
868
891
  if (trimmed !== '') {
869
892
  try {
@@ -872,23 +895,77 @@ function parse(res) {
872
895
  catch {
873
896
  if (ok)
874
897
  return undefined;
875
- throw new ApiError(res.status, 'invalid_response', res.text.slice(0, 500));
898
+ throw new ApiError(res.status, 'invalid_response', trimmed.slice(0, 500), undefined, res.headers);
876
899
  }
877
900
  }
878
901
  if (ok)
879
902
  return payload;
880
903
  if (isErrorBody(payload)) {
881
- throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details);
904
+ throw new ApiError(res.status, payload.error.code, payload.error.message, payload.error.details, res.headers);
905
+ }
906
+ throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`, undefined, res.headers);
907
+ }
908
+ /** `429`/`5xx` are the only statuses the §7 retry policy replays. */
909
+ function isRetryableStatus(status) {
910
+ return status === 429 || status >= 500;
911
+ }
912
+ /** A response abandoned mid-retry must have its stream drained, or the
913
+ * underlying connection (and, over a unix socket, the daemon's handler) is
914
+ * held open by a reader that will never arrive. */
915
+ async function drainResponseBody(res) {
916
+ try {
917
+ await res.body?.cancel();
918
+ }
919
+ catch {
920
+ // Best-effort — the response is being discarded either way.
882
921
  }
883
- throw new ApiError(res.status, 'internal', `request failed with status ${res.status}`);
922
+ }
923
+ /** Exponential backoff for the §7 retry policy: 250ms, 500ms, 1s, capped at 2s. */
924
+ function retryDelayMs(attempt) {
925
+ return Math.min(2_000, 250 * 2 ** (attempt - 1));
926
+ }
927
+ /** The underlying `errno`-style code beneath a fetch failure. Native `fetch`
928
+ * wraps a connection error as `TypeError('fetch failed', { cause })`; the
929
+ * Node-only socket `fetch` (`api/node-transport.ts`) preserves the same
930
+ * shape so both transports classify identically here. */
931
+ function transportErrorCode(err) {
932
+ if (typeof err !== 'object' || err === null)
933
+ return undefined;
934
+ const direct = err.code;
935
+ if (typeof direct === 'string')
936
+ return direct;
937
+ const cause = err.cause;
938
+ if (typeof cause === 'object' && cause !== null) {
939
+ const causeCode = cause.code;
940
+ if (typeof causeCode === 'string')
941
+ return causeCode;
942
+ }
943
+ return undefined;
944
+ }
945
+ /** True when a transport throw is the caller's OWN abort, as opposed to the
946
+ * client's internal per-request timeout — an aborted request must never be
947
+ * retried, whichever reason fired. */
948
+ function isAbortError(err, signal) {
949
+ if (signal?.aborted)
950
+ return true;
951
+ return err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError');
884
952
  }
885
953
  /** Map a transport-layer throw (never an HTTP status) to an `ApiError`. A
886
954
  * connection refusal here means the daemon is unreachable and autostart could
887
955
  * not recover it. */
888
- function toTransportApiError(err) {
956
+ function toTransportApiError(err, signal) {
957
+ if (signal?.aborted) {
958
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
959
+ }
889
960
  if (err instanceof ApiError)
890
961
  return err;
891
- const code = err?.code;
962
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
963
+ return new ApiError(504, 'request_timeout', `crtrd request timed out: ${err.message}`);
964
+ }
965
+ if (err instanceof DOMException && err.name === 'AbortError') {
966
+ return new ApiError(0, 'request_aborted', 'request aborted by caller signal');
967
+ }
968
+ const code = transportErrorCode(err);
892
969
  const message = err instanceof Error ? err.message : String(err);
893
970
  if (code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ENOTSOCK') {
894
971
  return new ApiError(503, 'daemon_unavailable', `crtrd is not reachable: ${message}`);
@@ -29,6 +29,15 @@ export interface BrokerSettleRequest extends BrokerExecutionRequest {
29
29
  backgroundJobsRunning: boolean;
30
30
  pushedFinal: boolean;
31
31
  }
32
+ /** `POST /v1/nodes/{id}/broker/telemetry` body. Tokens are cumulative within
33
+ * the broker's current Pi session; null context/activity values preserve the
34
+ * last usable value in the daemon projection, matching telemetry.json. */
35
+ export interface BrokerTelemetryRequest extends BrokerExecutionRequest {
36
+ tokens_in: number;
37
+ context_tokens: number | null;
38
+ last_activity: string | null;
39
+ updated_at: string;
40
+ }
32
41
  /** The only consequence a settle caller may enact. crtrd has already committed
33
42
  * every canvas and placement effect before returning this directive. */
34
43
  export type BrokerSettleDirective = {
@@ -1,5 +1,5 @@
1
1
  import type { Cursor, IsoTime, NodeIdDTO, NodeStatusDTO } from './common.js';
2
- import type { NodeSummaryDTO } from './nodes.js';
2
+ import type { NodeFaultDTO, NodeSummaryDTO } from './nodes.js';
3
3
  /** `GET /v1/nodes` + `GET /v1/status` composed for the dashboard view. */
4
4
  export interface DashboardQuery {
5
5
  /** Restrict to the subtree under this node. */
@@ -280,6 +280,28 @@ export interface RosterDTO {
280
280
  nodes: RosterNodeDTO[];
281
281
  edges: RosterEdgeDTO[];
282
282
  }
283
+ /** One row in `GET /v1/canvas/graph`: exactly the node fields the attach graph displays. */
284
+ export interface GraphNodeDTO {
285
+ node_id: NodeIdDTO;
286
+ name: string;
287
+ description: string | null;
288
+ cycles: number | null;
289
+ kind: string;
290
+ status: NodeStatusDTO;
291
+ frozen_at: IsoTime | null;
292
+ pi_pid: number | null;
293
+ telemetry_context_tokens: number | null;
294
+ telemetry_last_activity: string | null;
295
+ fault: NodeFaultDTO | null;
296
+ streaming: boolean;
297
+ }
298
+ /** `GET /v1/canvas/graph` — the attach graph's one-read projection: display rows, subscription topology, and the current local viewer-focus set. */
299
+ export interface GraphDTO {
300
+ generated_at: IsoTime;
301
+ nodes: GraphNodeDTO[];
302
+ edges: RosterEdgeDTO[];
303
+ focused_node_ids: NodeIdDTO[];
304
+ }
283
305
  /** `POST /v1/canvas/prune` body — the three exclusive prune modes of
284
306
  * `crtr canvas prune`, precedence COUNT (`limit`) > EMPTY (`empty`) > TTL sweep. */
285
307
  export interface PruneRequest {
@@ -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;
@@ -105,6 +105,14 @@ export interface NodeSummaryDTO {
105
105
  final_report: string | null;
106
106
  finalized_at: IsoTime | null;
107
107
  deadline_at: IsoTime | null;
108
+ /** Cumulative input tokens reported by the broker telemetry producer. */
109
+ telemetry_tokens_in?: number | null;
110
+ /** Latest context-window token gauge reported by the broker. */
111
+ telemetry_context_tokens?: number | null;
112
+ /** Latest tool summary reported by the broker. */
113
+ telemetry_last_activity?: string | null;
114
+ /** Timestamp of the latest broker telemetry update. */
115
+ telemetry_updated_at?: IsoTime | null;
108
116
  outcome: NodeOutcomeSummaryDTO | null;
109
117
  fault?: NodeFaultDTO | null;
110
118
  streaming?: boolean;
@@ -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,102 @@
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
+ const status = res.statusCode ?? 0;
69
+ resolvePromise(new Response(status === 204 ? null : body, { status, statusText: res.statusMessage, headers: responseHeaders }));
70
+ });
71
+ req.on('error', (err) => reject(new TypeError('fetch failed', { cause: err })));
72
+ const signal = init.signal;
73
+ const abort = () => {
74
+ req.destroy();
75
+ reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
76
+ };
77
+ if (signal !== null && signal !== undefined) {
78
+ if (signal.aborted) {
79
+ abort();
80
+ return;
81
+ }
82
+ signal.addEventListener('abort', abort, { once: true });
83
+ }
84
+ if (payload !== undefined)
85
+ req.write(payload);
86
+ req.end();
87
+ });
88
+ };
89
+ fetchOverSocket[LOCAL_SOCKET_FETCH] = true;
90
+ return fetchOverSocket;
91
+ }
92
+ /** Construct a client bound to the default local socket with autostart on.
93
+ * Without `onColdSocket`, a cold socket fails with `daemon_unavailable`. */
94
+ export function localClient(opts = {}) {
95
+ const { socketPath, ...rest } = opts;
96
+ return new CrtrClient({
97
+ baseUrl: 'http://localhost',
98
+ fetch: socketFetch(socketPath ?? defaultSocketPath()),
99
+ autostart: true,
100
+ ...rest,
101
+ });
102
+ }
@@ -33,6 +33,7 @@ export declare const routes: {
33
33
  readonly nodeBrokerSettle: (id: string) => string;
34
34
  readonly nodeBrokerParkComplete: (id: string) => string;
35
35
  readonly nodeBrokerParkActivity: (id: string) => string;
36
+ readonly nodeBrokerTelemetry: (id: string) => string;
36
37
  readonly nodeMailClaim: (id: string) => string;
37
38
  readonly nodeMailAcknowledge: (id: string) => string;
38
39
  readonly nodeBrokerModel: (id: string) => string;
@@ -75,6 +76,7 @@ export declare const routes: {
75
76
  readonly canvasHistoryStats: () => string;
76
77
  readonly canvasSnapshot: () => string;
77
78
  readonly canvasRoster: () => string;
79
+ readonly canvasGraph: () => string;
78
80
  readonly canvasPrune: () => string;
79
81
  readonly humanReviews: () => string;
80
82
  readonly humanReview: (reviewId: string) => string;
@@ -109,6 +111,7 @@ export declare const routes: {
109
111
  readonly profileResume: (name: string) => string;
110
112
  readonly profileMetadata: (name: string) => string;
111
113
  readonly modelAuths: () => string;
114
+ readonly modelAuthReadiness: () => string;
112
115
  readonly modelAuth: (provider: string) => string;
113
116
  readonly filePeek: () => string;
114
117
  readonly memoryResolve: () => string;
@@ -51,6 +51,7 @@ export const routes = {
51
51
  nodeBrokerSettle: (id) => `${V}/nodes/${id}/broker/settle`,
52
52
  nodeBrokerParkComplete: (id) => `${V}/nodes/${id}/broker/park-complete`,
53
53
  nodeBrokerParkActivity: (id) => `${V}/nodes/${id}/broker/park-activity`,
54
+ nodeBrokerTelemetry: (id) => `${V}/nodes/${id}/broker/telemetry`,
54
55
  nodeMailClaim: (id) => `${V}/nodes/${id}/mail/claim`,
55
56
  nodeMailAcknowledge: (id) => `${V}/nodes/${id}/mail/acknowledge`,
56
57
  nodeBrokerModel: (id) => `${V}/nodes/${id}/broker/model`,
@@ -97,6 +98,7 @@ export const routes = {
97
98
  canvasHistoryStats: () => `${V}/canvas/history/stats`,
98
99
  canvasSnapshot: () => `${V}/canvas/snapshot`,
99
100
  canvasRoster: () => `${V}/canvas/roster`,
101
+ canvasGraph: () => `${V}/canvas/graph`,
100
102
  canvasPrune: () => `${V}/canvas/prune`,
101
103
  // Daemon-owned document reviews and comments. All interpolated ids are
102
104
  // guarded by `CrtrClient` before they reach these pure builders.
@@ -138,6 +140,7 @@ export const routes = {
138
140
  profileMetadata: (name) => `${V}/profiles/${name}/metadata`,
139
141
  // Model auth
140
142
  modelAuths: () => `${V}/model-auth`,
143
+ modelAuthReadiness: () => `${V}/model-auth/readiness`,
141
144
  modelAuth: (provider) => `${V}/model-auth/${provider}`,
142
145
  // Host file read (browser file-peek panel). The absolute path rides as a
143
146
  // `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.321",
3
+ "version": "0.3.323",
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",