@north-light/crouter 0.3.323 → 0.3.325
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.
- package/dist/api/client.d.ts +12 -4
- package/dist/api/client.js +2 -2
- package/dist/api/dto/bash.d.ts +17 -0
- package/dist/api/dto/bash.js +0 -0
- package/dist/api/dto/files.d.ts +16 -6
- package/dist/api/dto/node-events.d.ts +65 -0
- package/dist/api/dto/node-events.js +0 -0
- package/dist/api/index.d.ts +3 -0
- package/dist/api/index.js +1 -1
- package/dist/api/routes.d.ts +4 -0
- package/dist/api/routes.js +1 -1
- package/dist/builtin-memory/crouter-sdk.md +11 -7
- package/dist/clients/attach/viewer.js +123 -123
- package/dist/core/runtime/boot-root.js +5 -1
- package/dist/core/runtime/run-command.d.ts +29 -0
- package/dist/core/runtime/run-command.js +1 -0
- package/dist/daemon/api/handlers/bash.d.ts +2 -0
- package/dist/daemon/api/handlers/bash.js +1 -0
- package/dist/daemon/api/handlers/files.js +1 -1
- package/dist/daemon/api/handlers/node-events.d.ts +36 -0
- package/dist/daemon/api/handlers/node-events.js +6 -0
- package/dist/daemon/api/handlers/reports.js +5 -5
- package/dist/daemon/api/server.js +3 -3
- package/dist/daemon/cron/capture.d.ts +3 -1
- package/dist/daemon/cron/capture.js +2 -2
- package/dist/daemon/cron-run.js +1 -1
- package/docs/sdk/README.md +9 -6
- package/docs/sdk/bash.md +34 -0
- package/docs/sdk/client.md +5 -7
- package/docs/sdk/errors.md +23 -18
- package/docs/sdk/files.md +44 -0
- package/docs/sdk/getting-started.md +1 -1
- package/docs/sdk/migration.md +2 -2
- package/docs/sdk/nodes.md +25 -28
- package/docs/sdk/resources.md +49 -41
- package/docs/sdk/streaming.md +54 -38
- package/package.json +1 -1
- package/runtime.lock.json +6 -6
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `client.files`
|
|
2
|
+
|
|
3
|
+
`client.files` reads, writes, and lists host files through the daemon.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const source = await client.files.read('/absolute/path/to/input.txt');
|
|
7
|
+
const written = await client.files.write('/absolute/path/to/output.txt', source.content);
|
|
8
|
+
const directory = await client.files.list('/absolute/path/to');
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Every `path` is absolute. Relative paths and `~` are not expanded. File paths are sent to the daemon unchanged; the daemon checks that they are absolute and returns a mapped API error when they are not.
|
|
12
|
+
|
|
13
|
+
Every method accepts ordinary request options in its final object: `headers`, `signal`, `timeout`, and `maxRetries`. `read` and `write` add `encoding`; `list` adds `limit`.
|
|
14
|
+
|
|
15
|
+
## Read
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
await client.files.read(path, { encoding: 'utf8', timeout: 5_000 });
|
|
19
|
+
await client.files.read(path, { encoding: 'base64' });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`read(path, options?)` returns `Promise<FilePeekDTO>`, `{ path, content, truncated }`, through `GET /v1/files/peek`. It captures at most 512 KiB of file bytes. `truncated: true` means `content` is only the prefix. Request `base64` for binary content.
|
|
23
|
+
|
|
24
|
+
## Write
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const result = await client.files.write(path, content, { encoding: 'utf8' });
|
|
28
|
+
// result is { path, bytes_written }
|
|
29
|
+
await client.files.write(path, bytes.toString('base64'), { encoding: 'base64' });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`write(path, content, options?)` returns `Promise<FileWriteDTO>`, `{ path, bytes_written }`. Writes create parent directories and atomically replace the target. If `path` is an existing symlink, the daemon resolves it and atomically replaces its destination; the symlink remains. Decoded content above 1 MiB is refused with `UnprocessableEntityError` carrying `status: 413` and `code: 'file_too_large'`; it is never truncated or partially written.
|
|
33
|
+
|
|
34
|
+
## List
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const result = await client.files.list(path, { limit: 100 });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`list(path, options?)` returns `Promise<FileListDTO>`, `{ path, entries, truncated }`, for one directory level. Each entry has `name`, `type: 'file' | 'dir' | 'other'`, `size`, and ISO-8601 `modified`. `limit` must be a positive integer; the default is 1000. `truncated: true` means the directory had more entries than the requested limit.
|
|
41
|
+
|
|
42
|
+
## Authority
|
|
43
|
+
|
|
44
|
+
The token is the owner credential. Anyone holding it can already create an agent with a bash tool, so these methods run with the daemon user's authority and do not provide a restricted file area.
|
|
@@ -156,5 +156,5 @@ An outcome is returned, never thrown — including a decline and a failure. Only
|
|
|
156
156
|
- Every constructor option and environment-variable fallback: [Client construction](./client.md)
|
|
157
157
|
- Checking the daemon connection and selected provider before a run: `client.auth.status()` above
|
|
158
158
|
- The full create-parameter table and the outcome union: [Nodes](./nodes.md)
|
|
159
|
-
- Watching a run as it works: [Streaming](./streaming.md)
|
|
159
|
+
- Watching a run as it works: [Streaming](./streaming.md)
|
|
160
160
|
- Running the daemon in a container instead: [Docker environment](./docker.md)
|
package/docs/sdk/migration.md
CHANGED
|
@@ -68,7 +68,7 @@ else console.error(run.reason, run.detail);
|
|
|
68
68
|
| `client.ensureProfile('x')` | `client.profiles.ensure('x')` |
|
|
69
69
|
| `schema` | `output_schema` |
|
|
70
70
|
| `signal` in the params object | `signal` in the second argument, the per-request options |
|
|
71
|
-
| `onEvent` | `client.nodes.stream()` — [Streaming](./streaming.md)
|
|
71
|
+
| `onEvent` | `client.nodes.stream()` — [Streaming](./streaming.md) |
|
|
72
72
|
| the `Environment` interface | deleted; nothing implements it |
|
|
73
73
|
|
|
74
74
|
## The result union changed
|
|
@@ -101,7 +101,7 @@ Aborting still stops **your client waiting**, not the run. Call `client.nodes.ca
|
|
|
101
101
|
- Reports are available from `client.nodes.reports.list(id)`.
|
|
102
102
|
- Settlement is the return of `waitForOutcome`.
|
|
103
103
|
|
|
104
|
-
Streaming
|
|
104
|
+
Streaming is strictly more capable than `onEvent` was: the three events it emitted are three of the events the stream carries, alongside assistant text deltas and tool calls.
|
|
105
105
|
|
|
106
106
|
## `CrtrClient` is no longer yours to construct
|
|
107
107
|
|
package/docs/sdk/nodes.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `client.nodes`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shipped, except where a row says otherwise.
|
|
4
4
|
|
|
5
5
|
A node is one agent run on the crouter canvas. It is asynchronous by construction: `create` returns as soon as the daemon has spawned it, and the run keeps working after your call returns.
|
|
6
6
|
|
|
@@ -20,20 +20,22 @@ There is one name for it — `client.nodes`. There is no `client.responses` alia
|
|
|
20
20
|
| `nodes.message(id, body)` | `POST /v1/nodes/{id}/messages` | 1 | Send a follow-up to a running or dormant node. |
|
|
21
21
|
| `nodes.interrupt(id)` | `POST /v1/nodes/{id}/interrupt` | 1 | Stop the current turn, keep the node. |
|
|
22
22
|
| `nodes.cancel(id, body?)` | `POST /v1/nodes/{id}/close` | 1 | Tears the node and its exclusive subtree down. `cancel` is the method name; `close` is the route. |
|
|
23
|
-
| `nodes.update(id, patch)` | `PATCH /v1/nodes/{id}/config` |
|
|
24
|
-
| `nodes.fork(id)` | `POST /v1/nodes/{id}/fork` |
|
|
25
|
-
| `nodes.revive(id, body?)` | `POST /v1/nodes/{id}/revive` |
|
|
26
|
-
| `nodes.promote(id, body?)` / `nodes.demote(id
|
|
27
|
-
| `nodes.recycle(id
|
|
28
|
-
| `nodes.yield(id, body?)` | `POST /v1/nodes/{id}/yield` |
|
|
29
|
-
| `nodes.wait(id, body
|
|
30
|
-
| `nodes.relaunchRoot(id
|
|
31
|
-
| `nodes.reviveAll(
|
|
32
|
-
| `nodes.stream(params)` | create + `GET /…/events` |
|
|
33
|
-
| `nodes.events(id,
|
|
23
|
+
| `nodes.update(id, patch)` | `PATCH /v1/nodes/{id}/config` | Shipped | |
|
|
24
|
+
| `nodes.fork(id)` | `POST /v1/nodes/{id}/fork` | Shipped | |
|
|
25
|
+
| `nodes.revive(id, body?)` | `POST /v1/nodes/{id}/revive` | Shipped | |
|
|
26
|
+
| `nodes.promote(id, body?)` / `nodes.demote(id)` | matching routes | Shipped | Move a node between base and orchestrator mode. |
|
|
27
|
+
| `nodes.recycle(id)` | `POST /v1/nodes/{id}/recycle` | Shipped | |
|
|
28
|
+
| `nodes.yield(id, body?)` | `POST /v1/nodes/{id}/yield` | Shipped | |
|
|
29
|
+
| `nodes.wait(id, body)` | `POST /v1/nodes/{id}/wait` | Shipped | |
|
|
30
|
+
| `nodes.relaunchRoot(id)` | matching route | Shipped | |
|
|
31
|
+
| `nodes.reviveAll()` | `POST /v1/nodes/revive-all` | Shipped | |
|
|
32
|
+
| `nodes.stream(params, options?)` | create + `GET /…/events` | Shipped | [Streaming](./streaming.md). |
|
|
33
|
+
| `nodes.events(id, options?)` | `GET /v1/nodes/{id}/events` | Shipped | [Streaming](./streaming.md). |
|
|
34
34
|
|
|
35
35
|
Action methods keep the product's literal name (`fork`, `revive`, `promote`, `yield`) rather than being renamed into a generic verb.
|
|
36
36
|
|
|
37
|
+
Every method in the table accepts `RequestOptions` as its final argument: `{ headers?, signal?, timeout?, maxRetries? }`. `nodes.stream(params, options?)` uses those options for create and stream opening, except that its stream has no wall-clock `timeout`; `nodes.events(id, options?)` takes `NodeEventsOptions`, which adds `after` and excludes `timeout`.
|
|
38
|
+
|
|
37
39
|
## Create parameters
|
|
38
40
|
|
|
39
41
|
Wire fields are `snake_case`. Every `NodeCreateParams` property is optional; `parse()` additionally requires `output_schema`. `NodeCreateParams` is the daemon's `CreateNodeRequest`, except `output_schema` also accepts an object. When neither `parent` nor `root` is supplied, the SDK sends `root: true`; otherwise fields pass through without camelizing.
|
|
@@ -165,21 +167,12 @@ Nested routes become nested properties.
|
|
|
165
167
|
|
|
166
168
|
| Property | Methods | Phase |
|
|
167
169
|
|---|---|---|
|
|
168
|
-
| `nodes.reports` | `list` |
|
|
169
|
-
| `nodes.
|
|
170
|
-
| `nodes.
|
|
171
|
-
| `nodes.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
| `nodes.snapshot` | `retrieve` | 2 |
|
|
175
|
-
| `nodes.subject` | `retrieve` | 2 |
|
|
176
|
-
| `nodes.outcomeDelivery` | `create`, `retrieve`, `delete` | 2 |
|
|
177
|
-
| `nodes.subscriptions` | `create`, `delete` | 2 |
|
|
178
|
-
| `nodes.jobs` | `list`, `cancel` | 2 |
|
|
179
|
-
| `nodes.worktree` | `close`, `abandon` | 2 |
|
|
180
|
-
| `nodes.result` | `submit` | 2 — only an agent inside a run calls this |
|
|
181
|
-
|
|
182
|
-
`nodes.reports.list(id)` is how an application shows progress before streaming lands: an agent pushes a report whenever it has something to say, and the list is newest-first.
|
|
170
|
+
| `nodes.reports` | `list` | Shipped |
|
|
171
|
+
| `nodes.jobs` | `list`, `cancel` | Shipped |
|
|
172
|
+
| `nodes.worktree` | `close`, `abandon` | Shipped |
|
|
173
|
+
| `nodes.result` | `submit` | Shipped — only an agent inside a run calls this |
|
|
174
|
+
|
|
175
|
+
`nodes.reports.list(id)` returns the agent's pushed progress reports, newest first. Use it when reports are the progress view your application needs; use [streaming](./streaming.md) for live output and tool-call events.
|
|
183
176
|
|
|
184
177
|
```ts
|
|
185
178
|
const reports = await client.nodes.reports.list(node.node_id, { limit: 10 });
|
|
@@ -190,4 +183,8 @@ for (const report of reports) console.log(report.tier, report.body);
|
|
|
190
183
|
|
|
191
184
|
List routes return what the daemon returns. `nodes.list`, `nodes.reports.list`, and `nodes.jobs.list` return plain arrays — there is no page object, no `hasNextPage()`, and no `after` cursor on them, because the daemon has no paging substrate behind those routes and an envelope there would promise a continuation that can never happen.
|
|
192
185
|
|
|
193
|
-
The one place the page envelope exists is the memory routes. See [Memory](./memory.md).
|
|
186
|
+
The one place the page envelope exists is the phase-3 memory routes. See [Memory](./memory.md).
|
|
187
|
+
|
|
188
|
+
## Identifier validation
|
|
189
|
+
|
|
190
|
+
Methods that take a node id validate it before the request: core node methods, lifecycle methods, `nodes.reports`, `nodes.jobs`, `nodes.worktree`, and `nodes.result`. An invalid id throws `TypeError` locally and sends no request. `nodes.outcome(id, { wait })` also throws `RangeError` locally when `wait` is not an integer from 0 through 25.
|
package/docs/sdk/resources.md
CHANGED
|
@@ -1,45 +1,53 @@
|
|
|
1
1
|
# Resource map
|
|
2
2
|
|
|
3
|
-
Every namespace
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
|
8
|
-
|
|
9
|
-
| `nodes` | `
|
|
10
|
-
| `nodes.
|
|
11
|
-
| `nodes.
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
3
|
+
Every namespace exported by the client. Namespaces are camelCase. Verbs are `create`, `retrieve`, `list`, `update`, `delete`, and `cancel`, except where the product already has a literal name for the action (`fork`, `revive`, `promote`, `pause`, `poke`) — in which case the SDK uses that name.
|
|
4
|
+
|
|
5
|
+
| Namespace | Methods | Routes |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `nodes` | `create`, `retrieve`, `list`, `update`, `cancel`, `interrupt`, `message`, `outcome`, `waitForOutcome`, `createAndWait`, `parse`, `stream`, `events`, `fork`, `revive`, `reviveAll`, `promote`, `demote`, `recycle`, `yield`, `wait`, `relaunchRoot` | `/v1/nodes…` |
|
|
8
|
+
| `nodes.reports` | `list` | `/v1/nodes/{id}/reports` |
|
|
9
|
+
| `nodes.jobs` | `list`, `cancel` | `/v1/nodes/{id}/jobs` |
|
|
10
|
+
| `nodes.worktree` | `close`, `abandon` | `/v1/nodes/{id}/worktree/…` |
|
|
11
|
+
| `nodes.result` | `submit` | `POST /v1/nodes/{id}/result` |
|
|
12
|
+
| `profiles` | `ensure`, `retrieve` | `/v1/profiles…` |
|
|
13
|
+
| `auth` | `status` | `GET /v1/status`, then `GET /v1/model-auth/readiness` once the daemon is ready |
|
|
14
|
+
| `system` | `status`, `health` | `/v1/status`, `/healthz` |
|
|
15
|
+
| `files` | `read`, `write`, `list` | `/v1/files/peek`, `/v1/files/write`, `/v1/files/list` |
|
|
16
|
+
| `bash` | `run` | `/v1/bash` |
|
|
17
|
+
| `canvas` | `attention`, `attentionCounts`, `snapshot`, `roster`, `dashboard`, `prune` | `/v1/canvas…` and composed node/status requests for `dashboard` |
|
|
18
|
+
| `canvas.history` | `search`, `grep`, `read`, `stats` | `/v1/canvas/history/…` |
|
|
19
|
+
| `crons` | `create`, `retrieve`, `list`, `pause`, `resume`, `run`, `delete`, `poke` | `/v1/crons…` |
|
|
20
|
+
| `human.requests` | `create`, `retrieve`, `replace`, `respond`, `dismiss`, `cancel` | `/v1/human/requests…` |
|
|
21
|
+
| `human.inbox` | `list`, `retrieve`, `respond`, `progress`, `cancel`, `history`, `response` | `/v1/human/inbox…` |
|
|
22
|
+
| `models.credentials` | `list`, `install`, `remove` | `/v1/model-auth…` |
|
|
23
|
+
| `models.config` | `update` | `PUT /v1/model-config` |
|
|
24
|
+
| `memory` | `list`, `retrieve`, `create`, `update`, `delete`, `move`, `search`, `history`, `resolve` | `/v1/memory…` — phase 3; see [Memory](./memory.md) |
|
|
25
|
+
|
|
26
|
+
Action methods keep the product's literal name (`fork`, `revive`, `promote`, `yield`) rather than being renamed into a generic verb. Every request-capable method takes `RequestOptions` as its final argument after its path, body, or query arguments. `RequestOptions` is `{ headers?, signal?, timeout?, maxRetries? }`; stream event options exclude `timeout` and add `after`.
|
|
27
|
+
|
|
28
|
+
## Files and bash
|
|
29
|
+
|
|
30
|
+
[`client.files`](./files.md) reads through `GET /v1/files/peek`, atomically writes, and lists absolute host paths. Reads and lists report `truncated`; writes over 1 MiB are refused instead of truncated. [`client.bash`](./bash.md) runs one command in a required absolute working directory and returns non-zero exits as values.
|
|
31
|
+
|
|
32
|
+
## Canvas
|
|
33
|
+
|
|
34
|
+
`client.canvas.attention()`, `attentionCounts(body)`, `snapshot()`, `roster()`, and `prune(body)` each send their matching canvas route. `dashboard(query?)` composes `GET /v1/nodes` and `GET /v1/status`, because the daemon deliberately has no dashboard route. `client.canvas.history.search(body)`, `grep(body)`, `read(query)`, and `stats(body)` wrap the four history routes.
|
|
35
|
+
|
|
36
|
+
## Crons
|
|
37
|
+
|
|
38
|
+
`client.crons.create(body)`, `retrieve(id, query?)`, `list(query?)`, `pause(id, query?)`, `resume(id, query?)`, `run(id, query?)`, `delete(id, query?)`, and `poke()` retain the daemon's cron DTOs and snake_case query fields. `delete` is the SDK verb for the daemon's `DELETE /v1/crons/{id}` route.
|
|
39
|
+
|
|
40
|
+
## Human requests and inbox
|
|
41
|
+
|
|
42
|
+
`client.human.requests` exposes `create`, `retrieve`, `replace`, `respond`, `dismiss`, and `cancel`. `client.human.inbox` exposes `list`, `retrieve`, `respond`, `progress`, `cancel`, `history`, and `response`. The inbox methods preserve the page protocol's nested camelCase fields and the daemon envelope's snake_case fields.
|
|
43
|
+
|
|
44
|
+
## Models
|
|
45
|
+
|
|
46
|
+
`client.models.credentials.list()`, `install(provider, body)`, and `remove(provider)` wrap `/v1/model-auth`. `client.models.config.update(body)` sends `PUT /v1/model-config`. Credential material is accepted only by `install`; list responses remain sanitized daemon DTOs.
|
|
47
|
+
|
|
48
|
+
## Identifier validation
|
|
49
|
+
|
|
50
|
+
Methods with node, cron, bash-job, human-request, inbox-ticket, provider, or profile identifiers validate them before calling the daemon. Invalid values throw `TypeError` locally and send no request. This applies to node action methods and nested node resources, cron methods that take an id, `human.requests` methods that take an id, `human.inbox` methods that take a ticket id, `models.credentials.install` and `remove`, and profile `ensure` and `retrieve`. File paths are not subject to this identifier check; the daemon validates their absolute-path contract.
|
|
43
51
|
|
|
44
52
|
## Excluded from the typed surface
|
|
45
53
|
|
|
@@ -68,4 +76,4 @@ const focuses = await client.request<FocusDTO[]>('GET', '/v1/focuses');
|
|
|
68
76
|
await client.request('POST', `/v1/nodes/${id}/some-new-route`, { field: 'value' }, { timeout: 5_000 });
|
|
69
77
|
```
|
|
70
78
|
|
|
71
|
-
If you find yourself reaching for `client.request()` for something an application genuinely needs, that route belongs in
|
|
79
|
+
If you find yourself reaching for `client.request()` for something an application genuinely needs, that route belongs in the table above. Say so rather than building on the escape hatch.
|
package/docs/sdk/streaming.md
CHANGED
|
@@ -1,42 +1,52 @@
|
|
|
1
|
-
> **Phase 2 — not shipped.** Nothing on this page exists yet. There is no HTTP streaming route on the daemon today, and `client.nodes.stream` and `client.nodes.events` are not on the client. Until phase 2 lands, an application shows progress with `client.nodes.reports.list` and gets the result with `client.nodes.waitForOutcome`.
|
|
2
|
-
|
|
3
1
|
# Streaming
|
|
4
2
|
|
|
5
3
|
Watch a run as it works: assistant text as it is produced, tool calls as they start and finish, reports as they are pushed, and the settled outcome.
|
|
6
4
|
|
|
7
5
|
## The SDK surface
|
|
8
6
|
|
|
9
|
-
<!-- TODO(verify): run against a live daemon once phase 2 lands. -->
|
|
10
|
-
|
|
11
7
|
```ts
|
|
12
|
-
const stream = client.nodes.stream({ prompt: 'Fix the failing test', cwd });
|
|
8
|
+
const stream = client.nodes.stream({ prompt: 'Fix the failing test', cwd }, { headers: { 'x-trace': 'run-9' } });
|
|
13
9
|
|
|
14
|
-
stream.on('node.output_text.delta', (
|
|
10
|
+
stream.on('node.output_text.delta', (event) => process.stdout.write(event.delta));
|
|
15
11
|
|
|
16
12
|
for await (const event of stream) {
|
|
17
13
|
// the same events, typed by `type`
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
const outcome = await stream.finalOutcome();
|
|
21
|
-
stream.abort();
|
|
16
|
+
const outcome = await stream.finalOutcome(); // resolves on node.settled
|
|
17
|
+
stream.abort(); // ends the HTTP response; the node keeps running
|
|
22
18
|
```
|
|
23
19
|
|
|
24
20
|
| Member | Behaviour |
|
|
25
21
|
|---|---|
|
|
26
|
-
| `client.nodes.stream(params)` | Creates the node, then opens its event stream. Returns **synchronously**; `stream.node` is a
|
|
27
|
-
| `client.nodes.events(id,
|
|
28
|
-
| `.on(type,
|
|
29
|
-
| `[Symbol.asyncIterator]` | Yields the same
|
|
30
|
-
| `.finalOutcome()` |
|
|
31
|
-
| `.abort()`
|
|
22
|
+
| `client.nodes.stream(params, options?)` | Creates the node, then opens its event stream. Returns **synchronously**; `stream.node` is a `Promise<NodeDetailDTO>` for the created node. The request options apply to creation and to opening the stream, except `timeout`, because a stream has no wall-clock timeout. |
|
|
23
|
+
| `client.nodes.events(id, options?)` | Validates and retrieves an existing node, then streams it. `options` is `NodeEventsOptions`: `after` plus request `headers`, `signal`, and `maxRetries`; it deliberately has no `timeout`. |
|
|
24
|
+
| `.on(type, listener)` / `.off(type, listener)` | Typed event emitter; each returns the `NodeStream`. |
|
|
25
|
+
| `[Symbol.asyncIterator]()` | Yields the same `NodeEventDTO` union. |
|
|
26
|
+
| `.finalOutcome()` | Returns `Promise<NodeOutcomeDTO>`, resolving on `node.settled` and rejecting on a terminal `error` event or a connection failure. |
|
|
27
|
+
| `.abort()` | Aborts the underlying `fetch`. An in-flight iterator and `finalOutcome()` reject with `APIUserAbortError`. Passing your own `signal` in the options aborts the stream the same way; `NodeStream` exposes no `signal` property. |
|
|
32
28
|
|
|
33
29
|
The method is called `events`, not `subscribe`: a subscription is already a push-delivery edge between two nodes on the canvas, and one word must not mean two things.
|
|
34
30
|
|
|
35
31
|
**A stream is an observer, never a lifecycle hold.** Disconnecting drops your subscriber and leaves the node running. Opening a stream never revives a dormant node — call `client.nodes.revive(id)` first if that is what you want.
|
|
36
32
|
|
|
33
|
+
## Activity helper
|
|
34
|
+
|
|
35
|
+
`followActivity(stream, { describe? })` turns streamed tool-call events into immutable `ActivityStep[]` snapshots for a plain activity feed. `describeToolDefault(tool, summary)` names the common tools; pass `describe` to use labels for your application or return `null` to hide a tool call. Tool summaries describe the argument shape, never argument values or raw arguments.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { followActivity } from '@north-light/crouter-sdk';
|
|
39
|
+
|
|
40
|
+
for await (const steps of followActivity(stream)) {
|
|
41
|
+
render(steps);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A step starts as `running`; a `node.tool_call.completed` event changes it to `done` when `status` is `ok` or `failed` when `status` is `error`.
|
|
46
|
+
|
|
37
47
|
## Events
|
|
38
48
|
|
|
39
|
-
|
|
49
|
+
Every event except `error` carries `node_id` and `sequence_number` in addition to the fields listed. `error` carries only its error object.
|
|
40
50
|
|
|
41
51
|
| Event | `data` |
|
|
42
52
|
|---|---|
|
|
@@ -47,11 +57,11 @@ Each event carries `node_id` and a `sequence_number` in addition to the fields l
|
|
|
47
57
|
| `node.turn.started` | `{ node_id, sequence_number }` |
|
|
48
58
|
| `node.turn.completed` | `{ node_id, sequence_number }` |
|
|
49
59
|
| `node.report.pushed` | `{ node_id, sequence_number, report }` |
|
|
50
|
-
| `node.status.changed` | `{ node_id, sequence_number, status }` |
|
|
60
|
+
| `node.status.changed` | `{ node_id, sequence_number, status }`, where `status` can also be stream-only `'dormant'` |
|
|
51
61
|
| `node.settled` | `{ node_id, sequence_number, outcome }` — terminal; the daemon ends the response after it |
|
|
52
|
-
| `error` | `{ error: { code, message, details
|
|
62
|
+
| `error` | `{ error: { code: 'stream_gap' \| 'stream_dropped' \| 'stream_error', message, details?: { earliest_sequence?: number } } }` — `stream_gap` is recoverable; the other codes terminate the response |
|
|
53
63
|
|
|
54
|
-
`summary` on a tool-call event
|
|
64
|
+
`summary` on a tool-call event states whether arguments are null, an array and its item count, an object and its field count, or a primitive type. It never includes argument values or tool output.
|
|
55
65
|
|
|
56
66
|
**Deliberately not carried:** thinking deltas, the model's tool-call construction deltas, raw tool output, and the system prompt. Those belong to the owner's viewer, not to an application's run stream.
|
|
57
67
|
|
|
@@ -68,57 +78,63 @@ Server-sent events: one record per event as `event: <type>`, `data: <JSON>`, bla
|
|
|
68
78
|
|
|
69
79
|
| Node state when you call | What the stream does |
|
|
70
80
|
|---|---|
|
|
71
|
-
| Already settled | Writes `node.settled` with the outcome and ends. Nothing is revived. |
|
|
81
|
+
| Already settled | Writes retained events when available, ending in `node.settled`; otherwise writes `node.settled` with the outcome and ends. Nothing is revived. |
|
|
72
82
|
| Running | Streams live, seeded as described below. |
|
|
73
83
|
| Dormant and not settled | Writes `node.status.changed { status: 'dormant' }` and holds the response open with keepalives. It starts streaming if and when the daemon brings a broker up for that node. |
|
|
74
84
|
|
|
75
|
-
A node can settle between your `create` and your `events` call, so both branches are ordinary. Either way the terminal event is `node.settled`, read from the same outcome row `nodes.outcome` reads — a client that joins after settlement and a client that was live at settlement see the same
|
|
85
|
+
A node can settle between your `create` and your `events` call, so both branches are ordinary. Either way the terminal event is `node.settled`, read from the same outcome row `nodes.outcome` reads — a client that joins after settlement and a client that was live at settlement see the same outcome.
|
|
76
86
|
|
|
77
87
|
## Sequence, resume, and failure
|
|
78
88
|
|
|
79
|
-
`sequence_number` is monotonic per node. The daemon keeps one in-memory ring per streamed node holding the last 512 events or 256 KiB, whichever binds first.
|
|
89
|
+
`sequence_number` is monotonic per node while that node's in-memory stream hub exists. The daemon keeps one in-memory ring per streamed node holding the last 512 events or 256 KiB, whichever binds first.
|
|
80
90
|
|
|
81
91
|
| Situation | Behaviour |
|
|
82
92
|
|---|---|
|
|
83
|
-
| You join a run already in progress |
|
|
93
|
+
| You join a run already in progress | First replays already-pushed reports oldest first, then seeds from the broker's snapshot: one `node.output_text.done` per completed assistant message, then one `node.output_text.delta` carrying the accumulated partial, then live. No content is lost — only delta granularity. |
|
|
84
94
|
| A second subscriber joins | Seeded from the ring, so both subscribers see the same sequence numbers. |
|
|
85
|
-
| `after=N` within the
|
|
86
|
-
| `after=N`
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
| The
|
|
95
|
+
| `after=N` within the retained range | Replays events after `N`. |
|
|
96
|
+
| `after=N` outside the retained range, including a future cursor | An `error` event with code **`stream_gap`** carries the earliest available sequence, then the stream continues from that point. It never silently skips. |
|
|
97
|
+
| One event exceeds the 256 KiB replay budget | Live subscribers receive it, but the daemon clears the resume ring. A cursor before that event gets `stream_gap`; a cursor on it resumes at the next retained event. |
|
|
98
|
+
| Your HTTP reader is too slow | The daemon ends the response with an `error` event whose code is **`stream_dropped`**. One slow reader cannot stall the engine. |
|
|
99
|
+
| The node's broker is replaced or its observer socket closes | The daemon reconnects and emits `node.status.changed`. Sequence continues while the stream hub remains in memory. |
|
|
100
|
+
| The daemon restarts | The ring is gone. A resuming client gets `stream_gap` with `earliest_sequence: 1`. There is no durable event log. |
|
|
90
101
|
| You disconnect | Your subscriber is dropped. The node keeps running. |
|
|
91
102
|
|
|
92
103
|
### Resuming
|
|
93
104
|
|
|
94
|
-
<!-- TODO(verify): run against a live daemon once phase 2 lands; confirm the field carrying the earliest available sequence on a `stream_gap` error. -->
|
|
95
|
-
|
|
96
105
|
```ts
|
|
106
|
+
import { APIError } from '@north-light/crouter-sdk';
|
|
107
|
+
|
|
97
108
|
let cursor: number | undefined;
|
|
98
109
|
|
|
99
110
|
for (;;) {
|
|
100
111
|
const stream = client.nodes.events(id, { after: cursor });
|
|
101
112
|
try {
|
|
102
113
|
for await (const event of stream) {
|
|
103
|
-
if (event.type === 'error'
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
if (event.type === 'error') {
|
|
115
|
+
if (event.error.code === 'stream_gap') {
|
|
116
|
+
console.warn('missed events before', event.error.details?.earliest_sequence);
|
|
117
|
+
continue; // the stream continues from the floor
|
|
118
|
+
}
|
|
119
|
+
throw new APIError(0, event.error.code, event.error.message, event.error.details);
|
|
106
120
|
}
|
|
107
121
|
cursor = event.sequence_number;
|
|
108
122
|
handle(event);
|
|
109
123
|
}
|
|
110
|
-
return;
|
|
111
|
-
} catch (
|
|
112
|
-
if (
|
|
113
|
-
throw
|
|
124
|
+
return; // ended on node.settled
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof APIError && error.code === 'stream_dropped') continue;
|
|
127
|
+
throw error;
|
|
114
128
|
}
|
|
115
129
|
}
|
|
116
130
|
```
|
|
117
131
|
|
|
118
132
|
`stream_gap` is recoverable and the stream continues after it. `stream_dropped` is terminal for that HTTP response — reconnect with the cursor you last saw.
|
|
119
133
|
|
|
134
|
+
## Identifier validation
|
|
135
|
+
|
|
136
|
+
`nodes.events(id, options?)` validates `id` before it requests the node or opens SSE. Because it returns a `NodeStream` synchronously, an invalid node identifier rejects `stream.node`, `stream.finalOutcome()`, and an iteration with `TypeError`; no request is sent. See [Errors](./errors.md#identifier-validation).
|
|
137
|
+
|
|
120
138
|
## Event shapes depend on the pinned pi version
|
|
121
139
|
|
|
122
140
|
The delta and tool-call shapes come from the installed `@earendil-works/pi-agent-core` and `pi-ai` packages, not from crouter. The daemon's translator is the one place that depends on them. A pi version bump must be checked against it.
|
|
123
|
-
|
|
124
|
-
<!-- TODO(verify): record the pi version the phase-2 event table was verified against once streaming lands. -->
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.325",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.325",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"workspaces": [
|
|
@@ -5206,20 +5206,20 @@
|
|
|
5206
5206
|
},
|
|
5207
5207
|
"packages/crouter-api": {
|
|
5208
5208
|
"name": "@north-light/crouter-api",
|
|
5209
|
-
"version": "0.3.
|
|
5209
|
+
"version": "0.3.325",
|
|
5210
5210
|
"license": "UNLICENSED"
|
|
5211
5211
|
},
|
|
5212
5212
|
"packages/crouter-env-docker": {
|
|
5213
5213
|
"name": "@north-light/crouter-env-docker",
|
|
5214
|
-
"version": "0.3.
|
|
5214
|
+
"version": "0.3.325",
|
|
5215
5215
|
"license": "UNLICENSED"
|
|
5216
5216
|
},
|
|
5217
5217
|
"packages/crouter-sdk": {
|
|
5218
5218
|
"name": "@north-light/crouter-sdk",
|
|
5219
|
-
"version": "0.3.
|
|
5219
|
+
"version": "0.3.325",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
|
-
"@north-light/crouter-api": "^0.3.
|
|
5222
|
+
"@north-light/crouter-api": "^0.3.322"
|
|
5223
5223
|
}
|
|
5224
5224
|
}
|
|
5225
5225
|
}
|