@north-light/crouter-api 0.3.156

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +51 -0
  2. package/dist/__tests__/client.test.d.ts +1 -0
  3. package/dist/__tests__/client.test.js +274 -0
  4. package/dist/client.d.ts +246 -0
  5. package/dist/client.js +611 -0
  6. package/dist/dto/attach.d.ts +16 -0
  7. package/dist/dto/attach.js +13 -0
  8. package/dist/dto/broker.d.ts +45 -0
  9. package/dist/dto/broker.js +20 -0
  10. package/dist/dto/canvas.d.ts +253 -0
  11. package/dist/dto/canvas.js +2 -0
  12. package/dist/dto/common.d.ts +27 -0
  13. package/dist/dto/common.js +15 -0
  14. package/dist/dto/config.d.ts +19 -0
  15. package/dist/dto/config.js +3 -0
  16. package/dist/dto/crons.d.ts +124 -0
  17. package/dist/dto/crons.js +10 -0
  18. package/dist/dto/files.d.ts +11 -0
  19. package/dist/dto/files.js +7 -0
  20. package/dist/dto/focus.d.ts +24 -0
  21. package/dist/dto/focus.js +10 -0
  22. package/dist/dto/health.d.ts +41 -0
  23. package/dist/dto/health.js +2 -0
  24. package/dist/dto/human.d.ts +57 -0
  25. package/dist/dto/human.js +4 -0
  26. package/dist/dto/inbox.d.ts +105 -0
  27. package/dist/dto/inbox.js +10 -0
  28. package/dist/dto/lifecycle.d.ts +79 -0
  29. package/dist/dto/lifecycle.js +3 -0
  30. package/dist/dto/messages.d.ts +55 -0
  31. package/dist/dto/messages.js +2 -0
  32. package/dist/dto/modelauth.d.ts +41 -0
  33. package/dist/dto/modelauth.js +3 -0
  34. package/dist/dto/nodes.d.ts +194 -0
  35. package/dist/dto/nodes.js +3 -0
  36. package/dist/dto/profiles.d.ts +14 -0
  37. package/dist/dto/profiles.js +3 -0
  38. package/dist/dto/reports.d.ts +41 -0
  39. package/dist/dto/reports.js +2 -0
  40. package/dist/dto/subscriptions.d.ts +14 -0
  41. package/dist/dto/subscriptions.js +2 -0
  42. package/dist/dto/worktree.d.ts +19 -0
  43. package/dist/dto/worktree.js +6 -0
  44. package/dist/errors.d.ts +19 -0
  45. package/dist/errors.js +30 -0
  46. package/dist/index.d.ts +24 -0
  47. package/dist/index.js +25 -0
  48. package/dist/routes.d.ts +63 -0
  49. package/dist/routes.js +91 -0
  50. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @north-light/crouter-api
2
+
3
+ The typed contract for **crtrd**'s `/v1` HTTP API — DTOs, route builders, the
4
+ error contract, and the `CrtrClient` transport. This is the client-side surface
5
+ external consumers (e.g. Northlight Core) use to talk to a crtrd instance
6
+ without depending on the full `@crouton-kit/crouter` runtime.
7
+
8
+ Zero runtime dependencies: the built package is Node built-ins (`node:http`,
9
+ `node:https`, `node:os`, `node:path`) plus its own modules only. No install
10
+ scripts.
11
+
12
+ > Private package (`"access": "restricted"`) published under the `@north-light`
13
+ > org. Not a general-purpose SDK.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm install @north-light/crouter-api
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { CrtrClient, type NodeSummaryDTO } from '@north-light/crouter-api';
25
+
26
+ // Talk to a remote crtrd over TCP (e.g. a Blaxel preview edge).
27
+ const client = new CrtrClient({ baseUrl: 'https://my-crtrd.example.com' });
28
+
29
+ const nodes: NodeSummaryDTO[] = await client.listNodes();
30
+ ```
31
+
32
+ `CrtrClient` also drives a local crtrd over its unix socket; see the
33
+ `CrtrClientOptions` type for `socketPath`, auth headers, and the `onColdSocket`
34
+ autostart hook.
35
+
36
+ ## What's exported
37
+
38
+ - **`CrtrClient`** — typed HTTP client wrapping every `/v1` route.
39
+ - **DTOs** — request/response types for nodes, lifecycle, messages, reports,
40
+ subscriptions, focuses, triggers, config, attach, profiles, model-auth,
41
+ canvas, worktree, and human-in-the-loop.
42
+ - **`routes` / `API_VERSION`** — pure path builders for the `/v1` surface.
43
+ - **`ApiError` / `ErrorBody` / `isErrorBody`** — the error contract.
44
+
45
+ ## Source of truth & releases
46
+
47
+ The source lives in the [`crouter`](https://github.com/vallum-security/crouter)
48
+ repo at `src/api/`; this package is a build-and-publish mirror of that subtree
49
+ (it carries no source of its own). It is published by the `publish-api.yml`
50
+ GitHub Action when a tag matching `api-vX.Y.Z` is pushed — the tag version must
51
+ match this package's `version`.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,274 @@
1
+ // client.test.ts — regression for issue #516: a cold-start `/healthz` timeout
2
+ // used to throw a bare `daemon_unavailable` message that discarded the actual
3
+ // startup failure (the tail of crtrd.err). `coldStartTimeoutMessage` is the
4
+ // pure composition `handleColdSocket` uses on timeout; this proves an injected
5
+ // diagnostic is appended (not dropped) and that a missing/empty diagnostic
6
+ // still falls back to the original bare message unchanged. `safeColdStartDiagnostic`
7
+ // is the guard `handleColdSocket` applies before that composition; this proves a
8
+ // THROWING hook is treated as absent (per the documented `CrtrClientOptions`
9
+ // contract) instead of propagating and replacing the typed `daemon_unavailable`
10
+ // error.
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { createServer } from 'node:http';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { coldStartTimeoutMessage, CrtrClient, safeColdStartDiagnostic } from '../client.js';
18
+ import { ApiError } from '../errors.js';
19
+ test('coldStartTimeoutMessage appends a present diagnostic to the base message', () => {
20
+ const msg = coldStartTimeoutMessage('crtrd.err (tail):\nError: bind EADDRINUSE');
21
+ assert.match(msg, /crtrd did not start/);
22
+ assert.match(msg, /crtrd\.err \(tail\):\nError: bind EADDRINUSE/);
23
+ });
24
+ test('coldStartTimeoutMessage falls back to the bare message when no diagnostic is available', () => {
25
+ assert.equal(coldStartTimeoutMessage(undefined), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
26
+ assert.equal(coldStartTimeoutMessage(''), 'crtrd did not start; run `crtr sys daemon start` and check crtrd.err.');
27
+ });
28
+ function startDelayedHealthzServer(socketPath, delayMs) {
29
+ const server = createServer((_req, res) => {
30
+ res.writeHead(200, { 'content-type': 'application/json' });
31
+ res.end(JSON.stringify({ ok: true }));
32
+ });
33
+ const timer = setTimeout(() => server.listen(socketPath), delayMs);
34
+ return { server, cancel: () => clearTimeout(timer) };
35
+ }
36
+ // REGRESSION: `crtr sys daemon restart` acks, then tears the old server down and
37
+ // hands over to a successor — a request crossing that window dies with a hang-up
38
+ // (ECONNRESET), not a refused connect. That used to surface as
39
+ // `daemon_unavailable: socket hang up` telling the caller to run
40
+ // `crtr sys daemon start` against a daemon that was already coming back up.
41
+ function startHandoverServer(socketPath) {
42
+ let dropped = false;
43
+ const server = createServer((req, res) => {
44
+ if (!dropped) {
45
+ dropped = true; // model the old server dying mid-request
46
+ req.socket.destroy();
47
+ return;
48
+ }
49
+ res.writeHead(200, { 'content-type': 'application/json' });
50
+ res.end(JSON.stringify({ ok: true, method: req.method }));
51
+ });
52
+ return {
53
+ server,
54
+ ready: new Promise((resolvePromise) => server.listen(socketPath, resolvePromise)),
55
+ };
56
+ }
57
+ test('a hang-up mid-request rides out the daemon handover and replays the idempotent call', async () => {
58
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
59
+ const socketPath = join(dir, 'crtrd.sock');
60
+ const { server, ready } = startHandoverServer(socketPath);
61
+ try {
62
+ await ready;
63
+ const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
64
+ assert.deepEqual(await client.request('GET', '/v1/nodes'), { ok: true, method: 'GET' });
65
+ }
66
+ finally {
67
+ server.close();
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ test('a hang-up mid-mutation reports daemon_restarting, not daemon_unavailable', async () => {
72
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-handover-'));
73
+ const socketPath = join(dir, 'crtrd.sock');
74
+ const { server, ready } = startHandoverServer(socketPath);
75
+ try {
76
+ await ready;
77
+ const client = new CrtrClient({ socketPath, autostart: false, coldStartPollWindowMs: 2_000 });
78
+ await assert.rejects(() => client.request('POST', '/v1/nodes', {}),
79
+ // Replaying is unsafe (the daemon may have applied it), so this one is the
80
+ // caller's call — but it must not be reported as a daemon that is down.
81
+ (err) => err instanceof ApiError && err.code === 'daemon_restarting');
82
+ }
83
+ finally {
84
+ server.close();
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ test('cliClient-style cold start fails loud when the injected poll window is shorter than the (valid) startup delay', async () => {
89
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
90
+ const socketPath = join(dir, 'crtrd.sock');
91
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
92
+ try {
93
+ const client = new CrtrClient({
94
+ socketPath,
95
+ autostart: true,
96
+ onColdSocket: async () => {
97
+ /* fire-and-forget: the delayed listen() above models ensureDaemon() */
98
+ },
99
+ coldStartPollWindowMs: 100, // shorter than the 300ms the daemon actually needs
100
+ });
101
+ await assert.rejects(() => client.healthz(), (err) => err instanceof ApiError && err.code === 'daemon_unavailable');
102
+ }
103
+ finally {
104
+ cancel(); // the client already timed out at 100ms; never let listen() fire at 300ms
105
+ server.close();
106
+ rmSync(dir, { recursive: true, force: true });
107
+ }
108
+ });
109
+ test('cliClient-style cold start succeeds when the injected poll window covers the (valid) startup delay', async () => {
110
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-coldstart-'));
111
+ const socketPath = join(dir, 'crtrd.sock');
112
+ const { server, cancel } = startDelayedHealthzServer(socketPath, 300);
113
+ try {
114
+ const client = new CrtrClient({
115
+ socketPath,
116
+ autostart: true,
117
+ onColdSocket: async () => {
118
+ /* fire-and-forget: the delayed listen() above models ensureDaemon() */
119
+ },
120
+ coldStartPollWindowMs: 2_000, // long enough to see the 300ms-delayed daemon come up
121
+ });
122
+ const health = await client.healthz();
123
+ assert.deepEqual(health, { ok: true });
124
+ }
125
+ finally {
126
+ cancel(); // already fired by the time we get here; harmless no-op
127
+ server.close();
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ test('safeColdStartDiagnostic returns undefined for an absent hook', () => {
132
+ assert.equal(safeColdStartDiagnostic(undefined), undefined);
133
+ });
134
+ test('safeColdStartDiagnostic returns the hook result when it succeeds', () => {
135
+ assert.equal(safeColdStartDiagnostic(() => 'crtrd.err (tail):\nboom'), 'crtrd.err (tail):\nboom');
136
+ });
137
+ test('safeColdStartDiagnostic treats a THROWING hook as absent, not a propagated error', () => {
138
+ assert.equal(safeColdStartDiagnostic(() => {
139
+ throw new Error('custom hook blew up');
140
+ }), undefined);
141
+ });
142
+ /** Spin a loopback server on a temp unix socket that records every request and
143
+ * replies with `respond(req)`. `respond` returning `undefined` means "handler
144
+ * not reached for this call" and is never exercised by these tests. */
145
+ async function startCapturingServer(respond) {
146
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-client-inbox-'));
147
+ const socketPath = join(dir, 'crtrd.sock');
148
+ const requests = [];
149
+ const server = createServer((req, res) => {
150
+ const chunks = [];
151
+ req.on('data', (chunk) => chunks.push(chunk));
152
+ req.on('end', () => {
153
+ const raw = Buffer.concat(chunks).toString('utf8');
154
+ const body = raw.trim() === '' ? undefined : JSON.parse(raw);
155
+ const captured = { method: req.method ?? '', path: req.url ?? '', body };
156
+ requests.push(captured);
157
+ const { status, body: resBody } = respond(captured);
158
+ res.writeHead(status, { 'content-type': 'application/json' });
159
+ res.end(JSON.stringify(resBody));
160
+ });
161
+ });
162
+ await new Promise((resolvePromise) => server.listen(socketPath, resolvePromise));
163
+ const client = new CrtrClient({ socketPath, autostart: false });
164
+ return {
165
+ client,
166
+ requests,
167
+ close: () => new Promise((resolvePromise, rejectPromise) => {
168
+ server.close((err) => (err ? rejectPromise(err) : resolvePromise()));
169
+ }).finally(() => rmSync(dir, { recursive: true, force: true })),
170
+ };
171
+ }
172
+ const SAMPLE_TICKET_ID = 'a'.repeat(64);
173
+ test('listHumanInbox sends GET /v1/human/inbox with no body and returns the parsed list', async () => {
174
+ const inboxList = { tickets: [{ ticket_id: SAMPLE_TICKET_ID, kind: 'deck', title: 'Approve deploy', blocked_since: '2026-01-01T00:00:00Z', source: {} }] };
175
+ const srv = await startCapturingServer(() => ({ status: 200, body: inboxList }));
176
+ try {
177
+ const result = await srv.client.listHumanInbox();
178
+ assert.deepEqual(result, inboxList);
179
+ assert.equal(srv.requests.length, 1);
180
+ assert.equal(srv.requests[0]?.method, 'GET');
181
+ assert.equal(srv.requests[0]?.path, '/v1/human/inbox');
182
+ assert.equal(srv.requests[0]?.body, undefined);
183
+ }
184
+ finally {
185
+ await srv.close();
186
+ }
187
+ });
188
+ test('getHumanInboxDeck sends GET /v1/human/inbox/:ticket_id with no body and returns the parsed deck', async () => {
189
+ const deck = { ticket_id: SAMPLE_TICKET_ID, kind: 'deck', deck: { title: 'Approve deploy', interactions: [] } };
190
+ const srv = await startCapturingServer(() => ({ status: 200, body: deck }));
191
+ try {
192
+ const result = await srv.client.getHumanInboxDeck(SAMPLE_TICKET_ID);
193
+ assert.deepEqual(result, deck);
194
+ assert.equal(srv.requests[0]?.method, 'GET');
195
+ assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}`);
196
+ assert.equal(srv.requests[0]?.body, undefined);
197
+ }
198
+ finally {
199
+ await srv.close();
200
+ }
201
+ });
202
+ test('respondHumanInboxDeck sends POST with the exact responses body and returns the canonical result unchanged', async () => {
203
+ const request = { responses: [{ id: 'notify', selectedOptionId: 'ok' }] };
204
+ const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: request.responses, summary: 'Acknowledged', completedAt: '2026-01-01T00:00:00Z' };
205
+ const srv = await startCapturingServer(() => ({ status: 200, body: result }));
206
+ try {
207
+ const got = await srv.client.respondHumanInboxDeck(SAMPLE_TICKET_ID, request);
208
+ assert.deepEqual(got, result);
209
+ assert.equal(srv.requests[0]?.method, 'POST');
210
+ assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}/respond`);
211
+ assert.deepEqual(srv.requests[0]?.body, request);
212
+ }
213
+ finally {
214
+ await srv.close();
215
+ }
216
+ });
217
+ test('cancelHumanInboxTicket sends POST with the reason body when a request is passed', async () => {
218
+ const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: '2026-01-01T00:00:00Z', reason: 'no longer needed', actor: 'human' };
219
+ const srv = await startCapturingServer(() => ({ status: 200, body: result }));
220
+ try {
221
+ const got = await srv.client.cancelHumanInboxTicket(SAMPLE_TICKET_ID, { reason: 'no longer needed' });
222
+ assert.deepEqual(got, result);
223
+ assert.equal(srv.requests[0]?.method, 'POST');
224
+ assert.equal(srv.requests[0]?.path, `/v1/human/inbox/${SAMPLE_TICKET_ID}/cancel`);
225
+ assert.deepEqual(srv.requests[0]?.body, { reason: 'no longer needed' });
226
+ }
227
+ finally {
228
+ await srv.close();
229
+ }
230
+ });
231
+ test('cancelHumanInboxTicket sends an empty object body when no request is passed', async () => {
232
+ const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: '2026-01-01T00:00:00Z' };
233
+ const srv = await startCapturingServer(() => ({ status: 200, body: result }));
234
+ try {
235
+ const got = await srv.client.cancelHumanInboxTicket(SAMPLE_TICKET_ID);
236
+ assert.deepEqual(got, result);
237
+ assert.deepEqual(srv.requests[0]?.body, {});
238
+ }
239
+ finally {
240
+ await srv.close();
241
+ }
242
+ });
243
+ test('a non-2xx inbox error body decodes to a typed ApiError (status/code/message)', async () => {
244
+ const srv = await startCapturingServer(() => ({
245
+ status: 409,
246
+ body: { error: { code: 'ticket_kind_unsupported', message: 'review tickets have no v1 response operation' } },
247
+ }));
248
+ try {
249
+ await assert.rejects(() => srv.client.respondHumanInboxDeck(SAMPLE_TICKET_ID, { responses: [] }), (err) => {
250
+ assert.ok(err instanceof ApiError);
251
+ assert.equal(err.status, 409);
252
+ assert.equal(err.code, 'ticket_kind_unsupported');
253
+ assert.match(err.message, /review tickets have no v1 response operation/);
254
+ return true;
255
+ });
256
+ }
257
+ finally {
258
+ await srv.close();
259
+ }
260
+ });
261
+ test('a malformed local ticket id throws a synchronous TypeError and never reaches the transport', async () => {
262
+ const srv = await startCapturingServer(() => {
263
+ throw new Error('handler must never be invoked for a locally-rejected ticket id');
264
+ });
265
+ try {
266
+ assert.throws(() => srv.client.getHumanInboxDeck('not-a-valid-hash'), TypeError);
267
+ assert.throws(() => srv.client.respondHumanInboxDeck('short', { responses: [] }), TypeError);
268
+ assert.throws(() => srv.client.cancelHumanInboxTicket('UPPERCASE'.repeat(7)), TypeError);
269
+ assert.equal(srv.requests.length, 0);
270
+ }
271
+ finally {
272
+ await srv.close();
273
+ }
274
+ });
@@ -0,0 +1,246 @@
1
+ import type { DaemonRestartDTO, HealthDTO, StatusDTO } from './dto/health.js';
2
+ import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
3
+ import type { InterruptResultDTO, MessageResultDTO, SendMessageRequest } from './dto/messages.js';
4
+ import type { PushReportRequest, PushReportResultDTO, ReportDTO, ReportsQuery } from './dto/reports.js';
5
+ import type { CloseRequest, CloseResultDTO, PromoteRequest, RelaunchRootResultDTO, ReviveRequest, ReviveResultDTO, WaitRequest, YieldRequest } from './dto/lifecycle.js';
6
+ import type { SubscribeRequest, SubscriptionDTO } from './dto/subscriptions.js';
7
+ import type { FocusDTO, RegisterFocusRequest, SetFocusPaneRequest } from './dto/focus.js';
8
+ import { type ArmCronRequest, type CronDTO, type CronRunDTO, type CronScopeQuery, type CronShowDTO, type ListCronsQuery } from './dto/crons.js';
9
+ import type { NodeConfigPatch } from './dto/config.js';
10
+ import type { AttachEnsureRequest, AttachEnsureResultDTO } from './dto/attach.js';
11
+ import type { EnsureProfileRequest, ProfileDTO } from './dto/profiles.js';
12
+ import type { FilePeekDTO } from './dto/files.js';
13
+ import type { CredentialResultDTO, InstallCredentialRequest } from './dto/modelauth.js';
14
+ import type { CreateHumanBridgeRequest, HumanBridgeResultDTO, HumanConsultResultDTO, HumanDeliverResultDTO, HumanVisualResultDTO } from './dto/human.js';
15
+ import type { CancelInboxTicketRequest, CanceledTicketResultDTO, DeckTicketResultDTO, InboxDeckDTO, InboxListDTO, InboxTicketIdDTO, RespondInboxDeckRequest } from './dto/inbox.js';
16
+ import type { AttentionCountsDTO, AttentionDTO, DashboardDTO, DashboardQuery, HistoryGrepQuery, HistoryGrepResultDTO, HistoryReadQuery, HistoryReadResultDTO, HistorySearchQuery, HistorySearchResultDTO, PruneRequest, PruneResultDTO, RebuildIndexResultDTO, RosterDTO, SnapshotDTO } from './dto/canvas.js';
17
+ import type { CloseWorktreeResultDTO } from './dto/worktree.js';
18
+ export interface CrtrClientOptions {
19
+ /** Unix socket path (default local transport). Exactly one of socketPath|baseUrl. */
20
+ socketPath?: string;
21
+ /** `http(s)://host:port` for TCP/remote transport. */
22
+ baseUrl?: string;
23
+ /** Extra headers (e.g. an edge auth token on TCP; crtrd ignores it). */
24
+ headers?: Record<string, string>;
25
+ /** Autostart on a cold socket (default true for socketPath, false for baseUrl). */
26
+ autostart?: boolean;
27
+ /** Per-request timeout in ms (default 30_000). */
28
+ timeoutMs?: number;
29
+ /** Injected daemon-start hook (spec §7.1). Called once on a cold socket when
30
+ * autostart is on; after it resolves, the client polls `/healthz` and retries
31
+ * the original request once. Absent → a cold socket throws `daemon_unavailable`. */
32
+ onColdSocket?: () => Promise<void>;
33
+ /** Injected cold-start diagnostic (issue #516). Called ONLY when the bounded
34
+ * `/healthz` poll times out after `onColdSocket`, so the caller can attach
35
+ * operator-useful context (e.g. a bounded tail of crtrd's stderr log) to the
36
+ * `daemon_unavailable` error instead of it staying a bare message. Must
37
+ * return synchronously and cheaply — it runs on the failure path, not the
38
+ * happy path. A thrown/undefined result is treated as "no diagnostic". */
39
+ coldStartDiagnostic?: () => string | undefined;
40
+ /** Bounded window (ms) to poll `/healthz` after `onColdSocket` resolves
41
+ * before giving up with `daemon_unavailable`. Defaults to
42
+ * `HEALTHZ_POLL_WINDOW_MS`. This must NOT silently drift from whatever
43
+ * window actually governs "did the daemon start" (issue #508 follow-up):
44
+ * `onColdSocket` is fire-and-forget, so this poll is the ONLY deadline that
45
+ * determines whether the CLI reports success. A caller whose `onColdSocket`
46
+ * hook triggers a differently-windowed startup verifier (e.g. the CLI's
47
+ * `ensureDaemon`/`verifyDaemonStartup`) must pass that same window here so
48
+ * a slow-but-valid cold start cannot pass the authoritative verifier while
49
+ * this poll times out first. */
50
+ coldStartPollWindowMs?: number;
51
+ }
52
+ export declare class CrtrClient {
53
+ private readonly socketPath?;
54
+ private readonly baseUrl?;
55
+ private readonly headers;
56
+ private readonly autostart;
57
+ private readonly timeoutMs;
58
+ private readonly onColdSocket?;
59
+ private readonly coldStartDiagnostic?;
60
+ private readonly coldStartPollWindowMs;
61
+ /** Guards against re-entering the autostart path more than once per client. */
62
+ private coldStartAttempted;
63
+ constructor(opts: CrtrClientOptions);
64
+ /** Construct a client bound to the default local socket with autostart on. Pass
65
+ * `onColdSocket` to enable the daemon-spawn hook (spec §7.1); without it a cold
66
+ * socket fails loud with `daemon_unavailable`. */
67
+ static forLocalSocket(opts?: Omit<CrtrClientOptions, 'socketPath' | 'baseUrl'>): CrtrClient;
68
+ healthz(): Promise<HealthDTO>;
69
+ status(): Promise<StatusDTO>;
70
+ /** Ask the daemon to replace itself with a successor running the currently
71
+ * selected runtime generation. Answers before the handover starts, so a
72
+ * caller living inside a node the handover will tear down still gets a
73
+ * settled result. */
74
+ restartDaemon(): Promise<DaemonRestartDTO>;
75
+ createNode(req: CreateNodeRequest): Promise<NodeDetailDTO>;
76
+ listNodes(q?: ListNodesQuery): Promise<NodeSummaryDTO[]>;
77
+ getNode(id: string): Promise<NodeDetailDTO>;
78
+ sendMessage(id: string, req: SendMessageRequest): Promise<MessageResultDTO>;
79
+ /** First-class interrupt (the human Esc): cancels pending undelivered
80
+ * human-send inbox entries, then aborts a live in-flight turn. NEVER
81
+ * revives a dormant target. */
82
+ interruptNode(id: string): Promise<InterruptResultDTO>;
83
+ pushReport(id: string, req: PushReportRequest): Promise<PushReportResultDTO>;
84
+ forkNode(id: string): Promise<NodeDetailDTO>;
85
+ reviveNode(id: string, req?: ReviveRequest): Promise<ReviveResultDTO>;
86
+ relaunchRoot(id: string): Promise<RelaunchRootResultDTO>;
87
+ closeNode(id: string, req?: CloseRequest): Promise<CloseResultDTO>;
88
+ recycleNode(id: string): Promise<NodeDetailDTO>;
89
+ demoteNode(id: string): Promise<NodeDetailDTO>;
90
+ promoteNode(id: string, req: PromoteRequest): Promise<NodeDetailDTO>;
91
+ yieldNode(id: string, req: YieldRequest): Promise<NodeDetailDTO>;
92
+ waitNode(id: string, req: WaitRequest): Promise<NodeDetailDTO>;
93
+ patchConfig(id: string, patch: NodeConfigPatch): Promise<NodeDetailDTO>;
94
+ /** Land + close the node's managed git worktree (spec §6.2). Server-side
95
+ * because it interleaves a canvas WRITE with a git land transaction and
96
+ * crtrd is the repo host (same principle as spawnChild's creation git). */
97
+ closeWorktree(id: string): Promise<CloseWorktreeResultDTO>;
98
+ subscribe(id: string, req: SubscribeRequest): Promise<SubscriptionDTO>;
99
+ listFocuses(): Promise<FocusDTO[]>;
100
+ focusOf(nodeId: string): Promise<FocusDTO | null>;
101
+ focusByPane(pane: string): Promise<FocusDTO | null>;
102
+ registerFocus(req: RegisterFocusRequest): Promise<FocusDTO | null>;
103
+ setFocusPane(focusId: string, req: SetFocusPaneRequest): Promise<void>;
104
+ closeFocus(focusId: string): Promise<void>;
105
+ unsubscribe(id: string, target: string): Promise<void>;
106
+ /** Arm one cron (`POST /v1/crons`) — the server mints the cron_id. */
107
+ armCron(req: ArmCronRequest): Promise<CronDTO>;
108
+ /** Crons visible to the caller (`GET /v1/crons`). With `q.profile` that is
109
+ * that profile's crons plus every global one; omit it only for a
110
+ * canvas-home-wide provenance read ("which crons did node X arm"). */
111
+ listCrons(q?: ListCronsQuery): Promise<CronDTO[]>;
112
+ /** One cron with its run-log ring (`GET /v1/crons/:cronId`). */
113
+ showCron(cronId: string, q?: CronScopeQuery): Promise<CronShowDTO>;
114
+ /** Pause one cron (`POST /v1/crons/:cronId/pause`) — stops firing, keeps config+history. */
115
+ pauseCron(cronId: string, q?: CronScopeQuery): Promise<CronDTO>;
116
+ /** Resume one paused cron (`POST /v1/crons/:cronId/resume`). */
117
+ resumeCron(cronId: string, q?: CronScopeQuery): Promise<CronDTO>;
118
+ /** Run one cron NOW, out of band (`POST /v1/crons/:cronId/run`) — synchronous:
119
+ * resolves with the settled run record after the subprocess closes. Does not
120
+ * advance the schedule or consume a one-shot; never escalates. */
121
+ runCron(cronId: string, q?: CronScopeQuery): Promise<CronRunDTO>;
122
+ /** Cancel one cron (`DELETE /v1/crons/:cronId`, idempotent). */
123
+ cancelCron(cronId: string, q?: CronScopeQuery): Promise<void>;
124
+ ensureAttach(id: string, req?: AttachEnsureRequest): Promise<AttachEnsureResultDTO>;
125
+ getReports(id: string, q?: ReportsQuery): Promise<ReportDTO[]>;
126
+ getTranscript(id: string, q?: TranscriptQuery): Promise<TranscriptDTO>;
127
+ getSnapshot(id: string): Promise<NodeSnapshotDTO>;
128
+ getArtifacts(id: string, q?: ArtifactsQuery): Promise<ArtifactListDTO>;
129
+ getContext(id: string): Promise<ContextListDTO>;
130
+ /** Read an absolute host path as UTF-8 (capped, `truncated` when clipped) for
131
+ * the browser file-peek panel. */
132
+ peekFile(path: string): Promise<FilePeekDTO>;
133
+ ensureProfile(name: string, req?: EnsureProfileRequest): Promise<ProfileDTO>;
134
+ listProfiles(): Promise<ProfileDTO[]>;
135
+ getProfile(name: string): Promise<ProfileDTO>;
136
+ /** Delete one profile by exact id or unique name (`DELETE /v1/profiles/:name`,
137
+ * idempotent — a miss is success). */
138
+ deleteProfile(name: string): Promise<void>;
139
+ installCredential(provider: string, req: InstallCredentialRequest): Promise<CredentialResultDTO>;
140
+ /** Create a terminal `kind:'human'` bridge node with NO broker engine
141
+ * (`spawnNode` server-side). Distinct from `createNode` (which launches a
142
+ * broker) precisely because a human bridge must never have one. */
143
+ createHumanBridge(req: CreateHumanBridgeRequest): Promise<HumanBridgeResultDTO>;
144
+ /** Run the registered humanloop completion handler server-side for one
145
+ * `humanloop.completion/v1` event. crtrd re-verifies the full trust binding
146
+ * before performing any canvas mutation. */
147
+ deliverHuman(event: unknown): Promise<HumanDeliverResultDTO>;
148
+ /** Run the registered follow-up handler server-side for one
149
+ * `humanloop.followup-request/v1` event. */
150
+ consultHuman(event: unknown): Promise<HumanConsultResultDTO>;
151
+ /** Run the registered visual handler server-side for one
152
+ * `humanloop.visual-request-event/v1` event. */
153
+ visualHuman(event: unknown): Promise<HumanVisualResultDTO>;
154
+ /** Pending deck/review tickets across every available crouter-owned
155
+ * humanloop root. */
156
+ listHumanInbox(): Promise<InboxListDTO>;
157
+ /** Read one pending deck by its opaque ticket id, with Markdown bodies
158
+ * resolved inline. */
159
+ getHumanInboxDeck(ticketId: InboxTicketIdDTO): Promise<InboxDeckDTO>;
160
+ /** Submit ordered interaction responses for a pending deck. Single-assignment
161
+ * server-side: a competing resolution races to `ticket_already_resolved`. */
162
+ respondHumanInboxDeck(ticketId: InboxTicketIdDTO, request: RespondInboxDeckRequest): Promise<DeckTicketResultDTO>;
163
+ /** Cancel a pending deck (terminal response, never deletion). */
164
+ cancelHumanInboxTicket(ticketId: InboxTicketIdDTO, request?: CancelInboxTicketRequest): Promise<CanceledTicketResultDTO>;
165
+ /** Composed client-side from `GET /v1/nodes` + `GET /v1/status` (spec §6.3 —
166
+ * the dashboard is absorbed into those two reads; there is no single route).
167
+ * `generated_at` is the client-side capture instant of the composition. */
168
+ dashboard(q?: DashboardQuery): Promise<DashboardDTO>;
169
+ attention(): Promise<AttentionDTO>;
170
+ /** Per-node pending-ticket counts for a bounded viewer slice. */
171
+ attentionCounts(node_ids: string[]): Promise<AttentionCountsDTO>;
172
+ /** Ranked/filtered content search over the per-cwd episodic corpus
173
+ * (`crtr canvas history search`). Optional query: ranked when present,
174
+ * recency browse when omitted. POST-bodied — the query carries arrays and
175
+ * free text; the whole search executes server-side (spec §6.3). */
176
+ historySearch(q: HistorySearchQuery): Promise<HistorySearchResultDTO>;
177
+ /** Required-pattern line-hit search over the per-cwd episodic corpus
178
+ * (`crtr canvas history grep`). Distinct stable schema from `historySearch`
179
+ * — POST-bodied for the same reasons. */
180
+ historyGrep(q: HistoryGrepQuery): Promise<HistoryGrepResultDTO>;
181
+ /** Resolve one `<node-id>:<relpath>` history ref to its full body
182
+ * (`crtr canvas history read`). */
183
+ historyRead(q: HistoryReadQuery): Promise<HistoryReadResultDTO>;
184
+ /** The machine-readable browser canvas roster (`crtr canvas snapshot`) —
185
+ * distinct from the per-node `getSnapshot`. */
186
+ canvasSnapshot(): Promise<SnapshotDTO>;
187
+ /** The lean, set-based topology roster (`GET /v1/canvas/roster`) — exactly
188
+ * two indexed queries server-side, no per-row enrichment. The recurring
189
+ * poll target for attach/browser topology; use `canvasSnapshot` for the
190
+ * enriched on-demand view. */
191
+ canvasRoster(): Promise<RosterDTO>;
192
+ prune(req: PruneRequest): Promise<PruneResultDTO>;
193
+ rebuildIndex(): Promise<RebuildIndexResultDTO>;
194
+ /** Raw request for routes not yet method-wrapped. Applies the same
195
+ * autostart + error-mapping semantics. */
196
+ request<T>(method: string, path: string, body?: unknown): Promise<T>;
197
+ private nodePath;
198
+ /** Validate a cron id before route construction — `routes.ts` interpolates
199
+ * it raw, so a value carrying `/`, whitespace or `?` would corrupt the
200
+ * request line rather than 404 cleanly. Mirrors `nodePath`. */
201
+ private cronPath;
202
+ /** Validate an opaque inbox ticket id before route construction. A local
203
+ * shape violation is a caller bug, not a server-rejectable request — throws
204
+ * `TypeError` (matching the existing safe-segment discipline of a local
205
+ * precondition, distinct from `nodePath`'s `ApiError` because that one IS a
206
+ * request the server could plausibly receive and reject itself). */
207
+ private ticketId;
208
+ private transport;
209
+ private isColdSocketError;
210
+ /** A connection torn down MID-request (Node's "socket hang up" / a broken
211
+ * pipe) — as distinct from a refused connect, which means nothing is
212
+ * listening. On the local socket that is `crtr sys daemon restart` doing its
213
+ * generation handover: the daemon acks, then tears itself down and hands
214
+ * over to a successor it spawned. The daemon IS coming back. */
215
+ private isHandoverHangup;
216
+ /** Wait for the successor to answer `/healthz`, then replay the request when
217
+ * replaying is safe. GET/HEAD are idempotent, so they retry transparently —
218
+ * the handover stays invisible, which is the whole point of a restart that
219
+ * resumes every node. A mutation may already have been applied server-side
220
+ * before the socket dropped, so it fails with `daemon_restarting` (retry),
221
+ * never `daemon_unavailable` ("start the daemon" is the wrong advice for a
222
+ * daemon that is mid-handover). */
223
+ private rideOutHandover;
224
+ /** Poll `/healthz` until the successor daemon answers, bounded by the
225
+ * cold-start window. Tolerates both the pre-listen gap (cold socket) and a
226
+ * second hang-up from a server still tearing down. */
227
+ private awaitHandover;
228
+ /** Trigger the injected daemon-start hook, poll `/healthz`, then let the caller
229
+ * retry once. Fail loud with `daemon_unavailable` when autostart is off, no
230
+ * hook is wired, or the daemon never becomes reachable. */
231
+ private handleColdSocket;
232
+ }
233
+ /** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
234
+ * appending the injected diagnostic (issue #516) when one is present instead
235
+ * of discarding the real startup failure behind a bare message. Exported (not
236
+ * from the package's public `index.ts` surface, which is dependency-light by
237
+ * design) purely so the regression test can assert the composition without
238
+ * waiting out the real poll window. */
239
+ export declare function coldStartTimeoutMessage(diagnostic: string | undefined): string;
240
+ /** Invoke the injected `coldStartDiagnostic` hook, treating a THROW the same
241
+ * as an absent/undefined result — the contract `CrtrClientOptions` documents
242
+ * ("a thrown/undefined result is treated as 'no diagnostic'"). Without this,
243
+ * a broken hook would propagate and replace the typed `daemon_unavailable`
244
+ * error the caller is entitled to. Exported alongside `coldStartTimeoutMessage`
245
+ * for the same direct-unit-test reason. */
246
+ export declare function safeColdStartDiagnostic(hook: (() => string | undefined) | undefined): string | undefined;