@north-light/crouter 0.3.324 → 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.
Files changed (37) hide show
  1. package/dist/api/client.d.ts +12 -4
  2. package/dist/api/client.js +2 -2
  3. package/dist/api/dto/bash.d.ts +17 -0
  4. package/dist/api/dto/bash.js +0 -0
  5. package/dist/api/dto/files.d.ts +16 -6
  6. package/dist/api/dto/node-events.d.ts +65 -0
  7. package/dist/api/dto/node-events.js +0 -0
  8. package/dist/api/index.d.ts +3 -0
  9. package/dist/api/index.js +1 -1
  10. package/dist/api/routes.d.ts +4 -0
  11. package/dist/api/routes.js +1 -1
  12. package/dist/builtin-memory/crouter-sdk.md +11 -7
  13. package/dist/clients/attach/viewer.js +123 -123
  14. package/dist/core/runtime/run-command.d.ts +29 -0
  15. package/dist/core/runtime/run-command.js +1 -0
  16. package/dist/daemon/api/handlers/bash.d.ts +2 -0
  17. package/dist/daemon/api/handlers/bash.js +1 -0
  18. package/dist/daemon/api/handlers/files.js +1 -1
  19. package/dist/daemon/api/handlers/node-events.d.ts +36 -0
  20. package/dist/daemon/api/handlers/node-events.js +6 -0
  21. package/dist/daemon/api/handlers/reports.js +5 -5
  22. package/dist/daemon/api/server.js +3 -3
  23. package/dist/daemon/cron/capture.d.ts +3 -1
  24. package/dist/daemon/cron/capture.js +2 -2
  25. package/dist/daemon/cron-run.js +1 -1
  26. package/docs/sdk/README.md +9 -6
  27. package/docs/sdk/bash.md +34 -0
  28. package/docs/sdk/client.md +5 -7
  29. package/docs/sdk/errors.md +23 -18
  30. package/docs/sdk/files.md +44 -0
  31. package/docs/sdk/getting-started.md +1 -1
  32. package/docs/sdk/migration.md +2 -2
  33. package/docs/sdk/nodes.md +25 -28
  34. package/docs/sdk/resources.md +49 -41
  35. package/docs/sdk/streaming.md +54 -38
  36. package/package.json +1 -1
  37. package/runtime.lock.json +6 -6
@@ -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), phase 2 |
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 (phase 2) 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.
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
- Phase 1, except where a row says otherwise.
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` | 2 | |
24
- | `nodes.fork(id)` | `POST /v1/nodes/{id}/fork` | 2 | |
25
- | `nodes.revive(id, body?)` | `POST /v1/nodes/{id}/revive` | 2 | |
26
- | `nodes.promote(id, body?)` / `nodes.demote(id, body?)` | matching routes | 2 | Move a node between base and orchestrator mode. |
27
- | `nodes.recycle(id, body?)` | `POST /v1/nodes/{id}/recycle` | 2 | |
28
- | `nodes.yield(id, body?)` | `POST /v1/nodes/{id}/yield` | 2 | |
29
- | `nodes.wait(id, body?)` | `POST /v1/nodes/{id}/wait` | 2 | |
30
- | `nodes.relaunchRoot(id, body?)` | matching route | 2 | |
31
- | `nodes.reviveAll(body?)` | `POST /v1/nodes/revive-all` | 2 | |
32
- | `nodes.stream(params)` | create + `GET /…/events` | 2 | [Streaming](./streaming.md). |
33
- | `nodes.events(id, opts?)` | `GET /v1/nodes/{id}/events` | 2 | [Streaming](./streaming.md). |
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` | 1 (`create` in 2) |
169
- | `nodes.messages` | `list` | 2 |
170
- | `nodes.artifacts` | `list` | 2 |
171
- | `nodes.context` | `list` | 2 |
172
- | `nodes.transcript` | `retrieve` | 2 |
173
- | `nodes.session` | `retrieve` | 2 |
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.
@@ -1,45 +1,53 @@
1
1
  # Resource map
2
2
 
3
- Every namespace on the client, with the phase it lands in. A namespace marked phase 2 or phase 3 does not exist yet.
4
-
5
- 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.
6
-
7
- | Namespace | Methods | Routes | Phase |
8
- |---|---|---|---|
9
- | `nodes` | `create`, `retrieve`, `list`, `cancel`, `interrupt`, `message`, `outcome`, `waitForOutcome`, `createAndWait`, `parse` | `/v1/nodes…` | 1; remaining methods in 2 |
10
- | `nodes.reports` | `list` | `/v1/nodes/{id}/reports` | 1 (`create` in 2) |
11
- | `nodes.messages` | `list` | `/v1/nodes/{id}/messages` | 2 |
12
- | `nodes.artifacts` | `list` | `/v1/nodes/{id}/artifacts` | 2 |
13
- | `nodes.context` | `list` | `/v1/nodes/{id}/context` | 2 |
14
- | `nodes.transcript` | `retrieve` | `/v1/nodes/{id}/transcript` | 2 |
15
- | `nodes.session` | `retrieve` | `/v1/nodes/{id}/session` | 2 |
16
- | `nodes.snapshot` | `retrieve` | `/v1/nodes/{id}/snapshot` | 2 |
17
- | `nodes.subject` | `retrieve` | `/v1/nodes/{id}/subject` | 2 |
18
- | `nodes.outcomeDelivery` | `create`, `retrieve`, `delete` | `/v1/nodes/{id}/outcome-delivery` | 2 |
19
- | `nodes.subscriptions` | `create`, `delete` | `/v1/nodes/{id}/subscriptions` | 2 |
20
- | `nodes.jobs` | `list`, `cancel` | `/v1/nodes/{id}/jobs` | 2 |
21
- | `nodes.worktree` | `close`, `abandon` | `/v1/nodes/{id}/worktree/…` | 2 |
22
- | `nodes.result` | `submit` | `POST /v1/nodes/{id}/result` | 2 — only an agent inside a run calls this |
23
- | `profiles` | `ensure`, `retrieve` | `/v1/profiles…` | 1; remaining methods in 2 |
24
- | `auth` | `status` | `GET /v1/status`, then `GET /v1/model-auth/readiness` once the daemon is ready | 1 |
25
- | `system` | `status`, `health`, `restart`, `admit`, `migrate` | `/v1/status`, `/healthz`, `/v1/daemon/…` | 1 for `status` and `health`; 3 for the rest |
26
- | `files` | `peek` | `/v1/files/peek` | 1 |
27
- | `bash` | — | — | 2 — reserved; not on the client today |
28
- | `llm` | — | — | Reserved; unbuilt |
29
- | `providers` | — | — | Reserved; unbuilt |
30
- | `canvas` | `attention`, `attentionCounts`, `snapshot`, `roster`, `dashboard`, `prune` | `/v1/canvas…` | 2 |
31
- | `canvas.history` | `search`, `grep`, `read`, `stats` | `/v1/canvas/history/…` | 2 |
32
- | `crons` | `create`, `retrieve`, `list`, `pause`, `resume`, `run`, `delete`, `poke` | `/v1/crons…` | 2 |
33
- | `human.requests` | `create`, `retrieve`, `replace`, `respond`, `dismiss`, `cancel` | `/v1/human/requests…` | 2 |
34
- | `human.inbox` | `list`, `retrieve`, `respond`, `progress`, `cancel`, `history`, `response` | `/v1/human/inbox…` | 2 |
35
- | `models.credentials` | `list`, `install`, `remove` | `/v1/model-auth…` | 2 |
36
- | `models.config` | `update` | `PUT /v1/model-config` | 2 |
37
- | `memory` | `list`, `retrieve`, `create`, `update`, `delete`, `move`, `search`, `history`, `resolve` | `/v1/memory…` | 3 — [Memory](./memory.md) |
38
- | `human.reviews` | `create`, `retrieve`, `list`, `submit`, `cancel`, `document` | `/v1/human/reviews…` | 3 |
39
- | `human.reviews.comments` / `human.comments` | `create`, `list`, `retrieve`, `edit`, `resolve`, `reopen`, `delete`, `fork`, `events`, `ranges` | `/v1/human/…comments…` | 3 |
40
- | `human.inbox.feedbackComments` | `create`, `resolve` | `/v1/human/inbox/{ticket}/feedback-comments…` | 3 |
41
- | `models.chatInventory` | `retrieve(nodeId)`, `prospective(query)` | `/v1/nodes/{id}/chat-inventory`, `/v1/prospective-chat-inventory` | 3 |
42
- | `worktrees` | `listQuarantined` | `/v1/worktrees/quarantined` | 3 |
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 one of the tables above. Say so rather than building on the escape hatch.
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.
@@ -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', (e) => process.stdout.write(e.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(); // resolves on node.settled
21
- stream.abort(); // ends the HTTP response; the node keeps running
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 promise for the created node. |
27
- | `client.nodes.events(id, { after?, signal? })` | Streams a node that already exists. |
28
- | `.on(type, cb)` / `.off(type, cb)` | Typed event emitter. |
29
- | `[Symbol.asyncIterator]` | Yields the same typed events. |
30
- | `.finalOutcome()` | Resolves with the `NodeOutcome` from `node.settled`; rejects on a terminal `error` event. |
31
- | `.abort()` / `signal` | Aborts the underlying `fetch`. Raises `APIUserAbortError` in an in-flight iteration. |
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
- Each event carries `node_id` and a `sequence_number` in addition to the fields listed.
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? } }` — terminal |
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 is a capped rendering of the arguments, never the raw arguments.
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 event.
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 | Seeded 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. |
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 ring | Replays from `N+1`. |
86
- | `after=N` older than the ring floor | An `error` event with code **`stream_gap`** carrying the earliest available sequence, **then the stream continues from the floor**. It never silently skips; you learn exactly what you lost. |
87
- | Your reader is too slow | The broker drops a client past 1000 queued frames or 32 MiB. You get an `error` with code **`stream_dropped`** and the response ends. One slow reader cannot stall the engine. |
88
- | The node's broker is replaced (revive, respawn) | The daemon reconnects and emits `node.status.changed`. Sequence continues. |
89
- | The daemon restarts | The ring is gone. A resuming client gets `stream_gap` from sequence 1. There is no durable event log. |
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' && event.error.code === 'stream_gap') {
104
- console.warn('missed events before', event.error.details?.earliest_sequence);
105
- continue; // the stream continues from the floor
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; // ended on node.settled
111
- } catch (e) {
112
- if (isDropped(e)) continue; // stream_dropped — reconnect from the cursor
113
- throw e;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.324",
3
+ "version": "0.3.325",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.324",
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.324",
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.324",
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.324",
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.324",
5219
+ "version": "0.3.325",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
- "@north-light/crouter-api": "^0.3.321"
5222
+ "@north-light/crouter-api": "^0.3.322"
5223
5223
  }
5224
5224
  }
5225
5225
  }