@north-light/cli 0.3.472 → 0.3.473

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 (43) hide show
  1. package/dist/api/dto/nodes.d.ts +1 -1
  2. package/dist/builtin-memory/crouter-sdk/README.md +1 -1
  3. package/dist/builtin-memory/crouter-sdk/errors.md +1 -1
  4. package/dist/builtin-memory/crouter-sdk/getting-started.md +1 -1
  5. package/dist/builtin-memory/crouter-sdk/guides/apps-on-northlight.md +1 -1
  6. package/dist/builtin-memory/crouter-sdk/nodes.md +1 -1
  7. package/dist/builtin-memory/crouter-sdk/release-notes.md +20 -20
  8. package/dist/commands/node/create.js +2 -2
  9. package/docs/apps/cli.md +40 -0
  10. package/docs/apps/coding-agents.md +72 -0
  11. package/docs/apps/concepts/apps-and-sign-in.md +82 -0
  12. package/docs/apps/concepts/connections.md +106 -0
  13. package/docs/apps/concepts/providers.md +59 -0
  14. package/docs/apps/concepts/runs.md +94 -0
  15. package/docs/apps/concepts/scopes-and-grants.md +62 -0
  16. package/docs/apps/concepts/testing-mode.md +36 -0
  17. package/docs/apps/errors.md +117 -0
  18. package/docs/apps/guides/background-runs.md +78 -0
  19. package/docs/apps/guides/files-and-documents.md +59 -0
  20. package/docs/apps/guides/go-live.md +37 -0
  21. package/docs/apps/guides/grant-changes.md +98 -0
  22. package/docs/apps/guides/manage-your-app.md +75 -0
  23. package/docs/apps/guides/many-users.md +61 -0
  24. package/docs/apps/guides/stream-a-run.md +82 -0
  25. package/docs/apps/guides/test-your-app.md +191 -0
  26. package/docs/apps/guides/typed-results.md +72 -0
  27. package/docs/apps/how-it-fits.md +46 -0
  28. package/docs/apps/providers/README.md +26 -0
  29. package/docs/apps/providers/context.md +32 -0
  30. package/docs/apps/providers/device.md +26 -0
  31. package/docs/apps/providers/integrations.md +26 -0
  32. package/docs/apps/providers/search.md +29 -0
  33. package/docs/apps/providers/sms.md +37 -0
  34. package/docs/apps/quickstart.md +120 -2
  35. package/docs/sdk/README.md +1 -1
  36. package/docs/sdk/errors.md +1 -1
  37. package/docs/sdk/getting-started.md +1 -1
  38. package/docs/sdk/guides/apps-on-northlight.md +1 -1
  39. package/docs/sdk/nodes.md +1 -1
  40. package/docs/sdk/release-notes.md +20 -20
  41. package/package.json +2 -2
  42. package/packages/crouter-identity/package.json +1 -1
  43. package/runtime.lock.json +12 -12
@@ -44,7 +44,7 @@ export interface CreateNodeRequest {
44
44
  parent?: NodeIdDTO | null;
45
45
  /** Node that created this request; absent for an external process. */
46
46
  creator?: NodeIdDTO;
47
- /** Per-run allow-list. Absent inherits everything. Some scopes are recorded but not enforced in beta: llm, files:<dir>, net, provider groups, and peers. */
47
+ /** Per-run allow-list. Absent inherits everything. A provider group (`<provider>:<group>`, e.g. `device:messages_send`) is enforced: a call to a group the list omits is refused `scope_missing` before any request leaves the machine; children inherit the list. Enforced on the broker route; on an unsandboxed host a same-UID process can bypass it by calling the daemon socket directly (crouter#146). */
48
48
  scopes?: string[];
49
49
  /** Sticky isolation; a child cannot turn off an isolated run. */
50
50
  isolated?: boolean;
@@ -84,7 +84,7 @@ The directory rejects the key until its public JWK is added to the app. To chang
84
84
  | [[crouter-sdk/errors]] | The error class table and the retry policy |
85
85
  | [[crouter-sdk/docker]] | `start()`, `attach()`, `connection()` from `@north-light/env-docker` |
86
86
  | [[crouter-sdk/testing]] | `FakeCloud` from `@north-light/sdk/testing`: a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch` |
87
- | [[crouter-sdk/release-notes]] | Behavior changes an existing app must act on, newest first |
87
+ | [Release notes](https://sdk.docs.northlight.ai/docs/release-notes) | Behavior changes an existing app must act on, newest first |
88
88
  | [[crouter-sdk/guides/apps-on-northlight]] | Building and registering an app against production (joining a laptop, `crtr cloud apps` registration, keys, env file, scopes, running locally, the `crtr cloud apps` commands); typed results from app runs with `runs.parse`, declines, bounding a run, `run_idle`, and waiting on a provider your app requires |
89
89
  | [[crouter-sdk/guides/apps-with-many-users]] | Per-person runtime connections, app-owned user access, privacy, and spending |
90
90
 
@@ -26,7 +26,7 @@ Every class below extends `APIError`, which carries `status`, `code`, `message`,
26
26
  | Condition | Class |
27
27
  |---|---|
28
28
  | SDK configuration error | `CrouterError` with `status: 0`, `code: 'crouter_error'` |
29
- | The runtime does not serve the contract level the SDK requires, or names none. The hosting router or the runtime refuses this *before* handling (the SDK sends `Crouter-Required-Contract` and a frozen `Crouter-Minimum-Runtime-Version`): nothing was accepted and no idempotency key was recorded. A runtime that predates the contract level handles the request first, and the SDK then raises after | `RuntimeVersionError` with `status: 426`, `code: 'runtime_version_unsupported'`; `requiredContract` and `runtimeContract` (or `null`) name both levels, `sdkVersion` and `runtimeVersion` stay for logging, and it is never retryable. A level is `MAJOR.MINOR`; the runtime satisfies it when it serves the same major and a minor at or above. See [[crouter-sdk/release-notes]] |
29
+ | The runtime does not serve the contract level the SDK requires, or names none. The hosting router or the runtime refuses this *before* handling (the SDK sends `Crouter-Required-Contract` and a frozen `Crouter-Minimum-Runtime-Version`): nothing was accepted and no idempotency key was recorded. A runtime that predates the contract level handles the request first, and the SDK then raises after | `RuntimeVersionError` with `status: 426`, `code: 'runtime_version_unsupported'`; `requiredContract` and `runtimeContract` (or `null`) name both levels, `sdkVersion` and `runtimeVersion` stay for logging, and it is never retryable. A level is `MAJOR.MINOR`; the runtime satisfies it when it serves the same major and a minor at or above. See [Release notes](https://sdk.docs.northlight.ai/docs/release-notes) |
30
30
  | HTTP 400 | `BadRequestError` |
31
31
  | HTTP 401 | `AuthenticationError` |
32
32
  | HTTP 403 | `PermissionDeniedError` |
@@ -27,7 +27,7 @@ npm i --save-exact @north-light/sdk@0.3.451
27
27
 
28
28
  One dependency. It re-exports every data type you need, so you do not also install `@north-light/api`.
29
29
 
30
- `@latest` always works against production: a release reaches `latest` only once production serves its contract level. The `next` tag carries every release the moment it is published and may need a newer runtime than production runs (see [[crouter-sdk/errors]] for `RuntimeVersionError`). Keep the exact pin: the compatibility gate protects the wire, not the TypeScript API. To move it, check `npm view @north-light/sdk dist-tags` and read the [[crouter-sdk/release-notes]] first.
30
+ `@latest` always works against production: a release reaches `latest` only once production serves its contract level. The `next` tag carries every release the moment it is published and may need a newer runtime than production runs (see [[crouter-sdk/errors]] for `RuntimeVersionError`). Keep the exact pin: the compatibility gate protects the wire, not the TypeScript API. To move it, check `npm view @north-light/sdk dist-tags` and read the [release notes](https://sdk.docs.northlight.ai/docs/release-notes) first.
31
31
 
32
32
  ## 3. Node, on the owner's own daemon
33
33
 
@@ -54,7 +54,7 @@ npm init -y
54
54
  npm i --save-exact @north-light/sdk@0.3.451
55
55
  ```
56
56
 
57
- `0.3.451` is the version this guide was last checked against on production. To move on purpose, run `npm view @north-light/sdk dist-tags`, read the [[crouter-sdk/release-notes]] for the versions in between, and install the new exact version the same way.
57
+ `0.3.451` is the version this guide was last checked against on production. To move on purpose, run `npm view @north-light/sdk dist-tags`, read the [release notes](https://sdk.docs.northlight.ai/docs/release-notes) for the versions in between, and install the new exact version the same way.
58
58
 
59
59
  Pin an exact version of `@north-light/sdk` rather than a range or `latest`. A compatibility gate between the SDK and Northlight's runtimes protects the wire, so an SDK that is too new for the runtime is refused cleanly. It does not protect the TypeScript API: a later SDK release can change a type or a method your code uses, and an unpinned install picks that up on its next `npm i`.
60
60
  If a call fails with `426 runtime_version_unsupported`, the SDK is newer than the Northlight runtimes. This can happen briefly right after a new SDK release, until production runtimes are upgraded. Install the previous version or wait, and tell Northlight at support@northlight.ai.
@@ -64,7 +64,7 @@ Wire fields are `snake_case`. Every `NodeCreateParams` property is optional; `pa
64
64
  | `description` | `string` | Display description. |
65
65
  | `parent` | node id | Graph placement. An external caller leaves this unset. |
66
66
  | `creator` | node id | Graph placement. An external caller leaves this unset. |
67
- | `scopes` | `string[]` | Per-run allow-list. Omit it to inherit every scope, or under a scoped token to receive that token's ceiling; a list outside the ceiling answers `403 scope_denied` with the offending scopes in `details.scopes`. Scopes are prefixed (`crtr:llm`, `crtr:act`, `crtr:schedule`, `crtr:memory:…`, `crtr:files:…`); `crtr:act` requires `crtr:llm`, and the list must stay inside what the creator's grant holds. Some scopes are recorded rather than enforced, per the `CreateNodeRequest` contract. See [[crouter-sdk/getting-started]]. |
67
+ | `scopes` | `string[]` | Per-run allow-list. Omit it to inherit every scope, or under a scoped token to receive that token's ceiling; a list outside the ceiling answers `403 scope_denied` with the offending scopes in `details.scopes`. Scopes are prefixed (`crtr:llm`, `crtr:act`, `crtr:schedule`, `crtr:memory:…`, `crtr:files:…`); `crtr:act` requires `crtr:llm`, and the list must stay inside what the creator's grant holds. Provider-group scopes (`device:messages_send`) are enforced on the broker route; on an unsandboxed host a same-UID process can bypass the list by calling the daemon socket directly, so it guards against a mistaken call, not a hostile node. Keep the list short: it travels in a request header on every device call. See [[crouter-sdk/getting-started]]. |
68
68
  | `worktree` | `string \| boolean` | Create a managed git worktree for the run. |
69
69
  | `fork_from` | `string` | Start from an existing conversation. |
70
70
  | `no_kickoff` | `boolean` | Create the node without sending the first message. |
@@ -73,28 +73,28 @@ The SDK no longer refuses a runtime because its release number is lower than the
73
73
 
74
74
  Not breaking: no type or wire shape an app reads is removed or changed.
75
75
 
76
- - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [[crouter-sdk/client]].
77
- - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [[crouter-sdk/client]].
78
- - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [[crouter-sdk/testing]].
76
+ - An app Northlight has not verified is in testing: only its admin and named testers can sign in. A refused sign-in redirects with `error=access_denied`, `error_description=app_in_testing`; a refused refresh answers `invalid_grant` `app_in_testing`. The refresh refusal is a new `Refusal` with `code: 'app_in_testing'` and `kind: 'paused'`, like `grant_suspended`: keep the person's data. If you `switch` exhaustively on `refusal.code`, add the case. See [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
77
+ - New `authorizationRefusalOf(callback)` reads the sign-in refusal from the callback's query and returns `AuthorizationRefusal` (`{code: 'app_in_testing', error: 'access_denied', state}`), else `null`. See [When sign-in is refused](/docs/concepts/apps-and-sign-in#when-the-directory-refuses-to-sign-someone-in).
78
+ - `@north-light/sdk/testing`: `cloud.testing` (`enable`, `disable`, `active`, `addTester`, `removeTester`, `testers`, `allowed`), `cloud.authorize(userId, {redirectUri, state})`, `user.setAccount({email, emailVerified, phone})`, and `'app_in_testing'` in `FakeRefusalCode`. The fake directory now serves `grant_type=authorization_code` for codes `cloud.authorize` issued. See [Testing mode](/docs/guides/test-your-app#testing-mode).
79
79
 
80
80
  ## 0.3.415
81
81
 
82
82
  ### Monthly spend cap enforced; `usage.get` (additive; one behavior change)
83
83
 
84
84
  - **Behavior change:** user-paid model calls (`/v1/llm`) and provider calls now count against the grant's `spend_cap` (from `grant.get()`; Northlight sets $50) for the current UTC month. Once the month reaches it, the next call answers 402 `cap_exhausted` (a `RateLimitError`, `retryable: false`, `userAction: 'raise_cap'`, `resetAt` the first instant of next month) before any model or provider is called. A run whose model call is refused this way ends with outcome reason `cap_exhausted`. The check runs before each call, so the call that crosses the cap still completes. App-paid usage never counts. A grant with `spend_cap: null` is not capped.
85
- - New `client.usage.get({month?})` (`month` is `YYYY-MM`, UTC; default the current month) answers `SpendUsageDTO`: `usd_equivalent` (user-paid spend, provider receipts included), `by_model`, `app_paid`, `spend_cap` and `reset_at`. See [[crouter-sdk/client]].
85
+ - New `client.usage.get({month?})` (`month` is `YYYY-MM`, UTC; default the current month) answers `SpendUsageDTO`: `usd_equivalent` (user-paid spend, provider receipts included), `by_model`, `app_paid`, `spend_cap` and `reset_at`. See [Show what a person's month has cost](/docs/guides/grant-changes#show-what-a-persons-month-has-cost).
86
86
  - A completion's `usage.usd_equivalent` is now the served model's list-price cost; before it was always `null`. It stays `null` for a model with no list price, and that usage adds nothing to the month.
87
87
 
88
88
  ### `npx @north-light/sdk env` (additive); keygen on an unjoined machine exits 2
89
89
 
90
- - New `npx @north-light/sdk env --app <client id> [--out <path>]` writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` from `crtr cloud apps get` into `local/app.env`, keeping every other line. See [[crouter-sdk/getting-started]].
90
+ - New `npx @north-light/sdk env --app <client id> [--out <path>]` writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` from `crtr cloud apps get` into `local/app.env`, keeping every other line. See [Go live with an app key](/docs/guides/go-live).
91
91
  - `keygen --app` on a machine whose `crtr` isn't joined to a Northlight account now prints the join step and the manual steps and exits 2. A missing `crtr` keeps its message and exit 0.
92
92
 
93
93
  ### One deadline per SDK call, and a deadline on every OAuth/JWKS fetch (additive, with one behaviour change)
94
94
 
95
95
  Not breaking: no type or wire shape an app reads is removed or changed. Apps pinned to the previous release keep working unchanged.
96
96
 
97
- - `RequestOptions` gains `budgetMs` and `deadlineAt`: one absolute deadline for the call covering every attempt, every retry wait (including a `Retry-After`) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the new `DeadlineExceededError` (`code: 'deadline_exceeded'`, `reason: 'elapsed' | 'retry_after'`, `deadlineAt`, `cause`), which extends `APIConnectionTimeoutError`, so code catching that still catches it. A transport that ignores its `AbortSignal` can no longer outlive the deadline. Without `budgetMs`/`deadlineAt` nothing about retries changes. See [[crouter-sdk/client]].
97
+ - `RequestOptions` gains `budgetMs` and `deadlineAt`: one absolute deadline for the call covering every attempt, every retry wait (including a `Retry-After`) and the token fetch/refresh. A wait that would pass the deadline rejects at once with the new `DeadlineExceededError` (`code: 'deadline_exceeded'`, `reason: 'elapsed' | 'retry_after'`, `deadlineAt`, `cause`), which extends `APIConnectionTimeoutError`, so code catching that still catches it. A transport that ignores its `AbortSignal` can no longer outlive the deadline. Without `budgetMs`/`deadlineAt` nothing about retries changes. See [One deadline per call](/docs/errors#retries).
98
98
  - An app that holds a lease or lock while it makes an SDK call should pass `budgetMs` and keep the lease longer than it, with margin, and state that in code.
99
99
  - `OAuth2Options.timeoutMs` (default 10 s): one deadline per directory operation (discovery, token exchange, refresh, revoke, `runtime_provisioning` retries and their `retry_after_s`). A refresh under the connection-store lock now holds the lock at most `timeoutMs`. `JwksCache` (and so `verifyIdToken`) takes `{timeoutMs}` (default 10 s) over its discovery and key-set reads together.
100
100
  - **Behaviour change:** these directory fetches used to have no deadline (only discovery had one). A directory that takes longer than 10 s now fails with `DeadlineExceededError` where it used to wait; set `timeoutMs` to change it.
@@ -114,11 +114,11 @@ Not breaking: no type or wire shape an app reads is removed or changed. Apps pin
114
114
 
115
115
  ### `@north-light/sdk/testing` (additive)
116
116
 
117
- New `@north-light/sdk/testing`: `FakeCloud`, a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch`. It covers runs (events, replies, long-poll, idempotency, endings), grant watch, refusals, directory failures, memory, providers and run notices, and is checked against the real runtime on every release. Additive: `verifyRunNotice` accepts `fetch`. No behaviour change. See [[crouter-sdk/testing]].
117
+ New `@north-light/sdk/testing`: `FakeCloud`, a fake directory and runtime your real `OAuth2Client` and `Crouter` talk to through `fetch`. It covers runs (events, replies, long-poll, idempotency, endings), grant watch, refusals, directory failures, memory, providers and run notices, and is checked against the real runtime on every release. Additive: `verifyRunNotice` accepts `fetch`. No behaviour change. See [Testing your app](/docs/guides/test-your-app).
118
118
 
119
119
  ### `oauth.accountAppUrl()` (additive), and a failed discovery read is retried (behaviour fix)
120
120
 
121
- - New `OAuth2Client.accountAppUrl()` returns the app's page on the signed-in person's Northlight account, as the directory's discovery document advertises it (`account_app_uri_template`). Use it instead of building `<issuer>/account/apps/<client id>` yourself. It needs no user or connection and throws `CrouterError` naming the fault when the directory advertises no template or an invalid one. See [[crouter-sdk/client]].
121
+ - New `OAuth2Client.accountAppUrl()` returns the app's page on the signed-in person's Northlight account, as the directory's discovery document advertises it (`account_app_uri_template`). Use it instead of building `<issuer>/account/apps/<client id>` yourself. It needs no user or connection and throws `CrouterError` naming the fault when the directory advertises no template or an invalid one. See [Link to the app's settings on Northlight](/docs/guides/grant-changes#link-to-the-apps-settings-on-northlight).
122
122
  - **Behaviour fix:** `OAuth2Client`'s discovery read now times out after 10 seconds, and a read that failed or timed out is retried by the next call that needs it. Before, one failed read was kept for the client's life, so every later `authorizeUrl`, `exchangeCode`, refresh and `verifyIdToken` failed until the process restarted, and a hung directory stalled them indefinitely. A successful read is still kept for the client's life.
123
123
 
124
124
  ## 0.3.405
@@ -169,25 +169,25 @@ The runtime gains what a sandbox that sleeps needs; no app-facing type or route
169
169
 
170
170
  ### Follow a person's grant with `oauth.watchGrants` (additive)
171
171
 
172
- `grant.get()` answers a new field, `version`: opaque, it changes exactly when `rev`, `resolved_scopes`, `spend_cap`, `payer` or `dependencies` changes. `grant.get({wait, since})` (`wait` an integer 1–25; anything else throws `RangeError` before sending) holds the read until the grant's `version` differs from `since` or `wait` seconds pass, and extends the request timeout by `wait`. A grant revoked or removed during the hold answers 401 `grant_revoked`. `oauth.watchGrants({users, onChanged, onError?, sweepMs?, concurrency?, wait?})` returns a `GrantWatcher` that follows the grants of the people `users()` names and calls `onChanged({userId, grant, previous})` level-triggered, at least once per version, so an app learns when a required provider's setup turns `ready` without a browser tab or a poll loop. New exports `GrantWatcher`, `GrantWatchOptions`, `GrantChanged`. Not breaking: nothing existing changes shape, and `RunWatcher` behaves exactly as before. The hold needs a runtime at this release and, for a hosted runtime, a hosting router that lets `GET /v1/grant?wait=N` through (before then a held read answers 504 after 10 s and the watcher backs off). See [[crouter-sdk/client]].
172
+ `grant.get()` answers a new field, `version`: opaque, it changes exactly when `rev`, `resolved_scopes`, `spend_cap`, `payer` or `dependencies` changes. `grant.get({wait, since})` (`wait` an integer 1–25; anything else throws `RangeError` before sending) holds the read until the grant's `version` differs from `since` or `wait` seconds pass, and extends the request timeout by `wait`. A grant revoked or removed during the hold answers 401 `grant_revoked`. `oauth.watchGrants({users, onChanged, onError?, sweepMs?, concurrency?, wait?})` returns a `GrantWatcher` that follows the grants of the people `users()` names and calls `onChanged({userId, grant, previous})` level-triggered, at least once per version, so an app learns when a required provider's setup turns `ready` without a browser tab or a poll loop. New exports `GrantWatcher`, `GrantWatchOptions`, `GrantChanged`. Not breaking: nothing existing changes shape, and `RunWatcher` behaves exactly as before. The hold needs a runtime at this release and, for a hosted runtime, a hosting router that lets `GET /v1/grant?wait=N` through (before then a held read answers 504 after 10 s and the watcher backs off). See [Learn when a person's grant changes](/docs/guides/grant-changes#follow-grants-with-watchgrants).
173
173
 
174
174
  ## 0.3.399
175
175
 
176
176
  ### `onActivity` no longer blocks the watcher
177
177
 
178
- In 0.3.398 the watcher awaited `onActivity` before its next read, so a slow or hung hook delayed that run's `onEnded`, held its `concurrency` slot, and kept `stop()` from resolving. The watcher now never awaits it: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, and `stop()` does not wait for a call already running. A call can now finish after that run's `onEnded`: guard the write on the row still waiting on the run. Runs that leave `pending()` without an ending no longer keep their last phase in memory. See [[crouter-sdk/client]].
178
+ In 0.3.398 the watcher awaited `onActivity` before its next read, so a slow or hung hook delayed that run's `onEnded`, held its `concurrency` slot, and kept `stop()` from resolving. The watcher now never awaits it: at most one call per run runs at a time, a newer reading taken meanwhile replaces any still waiting, and `stop()` does not wait for a call already running. A call can now finish after that run's `onEnded`: guard the write on the row still waiting on the run. Runs that leave `pending()` without an ending no longer keep their last phase in memory. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
179
179
 
180
180
  ## 0.3.398
181
181
 
182
182
  ### `RunWatcher` reports live progress with `onActivity` (additive)
183
183
 
184
- `oauth.watchRuns({..., onActivity, activityWait?})` calls `onActivity({userId, runId, activity, run})` with each read of a still-`running` run whose `activity` differs from the last one handed over for that run (compared as JSON: `phase`, `tool.name`, `tool.summary`), so an app can show a progress badge from the watcher it already runs instead of a second `runs.get` per page read. It is never called for a run at rest. With `onActivity` set, running runs are read with `wait: activityWait` (integer 1–25, default 2) rather than 25, because the runtime's long-poll answers early only at rest. Best effort: a throwing `onActivity` goes to `onError` and is not retried, and `onEnded` delivery is unchanged. Not breaking: without `onActivity` the watcher behaves exactly as before. New exported type `RunActivityEvent`. See [[crouter-sdk/client]].
184
+ `oauth.watchRuns({..., onActivity, activityWait?})` calls `onActivity({userId, runId, activity, run})` with each read of a still-`running` run whose `activity` differs from the last one handed over for that run (compared as JSON: `phase`, `tool.name`, `tool.summary`), so an app can show a progress badge from the watcher it already runs instead of a second `runs.get` per page read. It is never called for a run at rest. With `onActivity` set, running runs are read with `wait: activityWait` (integer 1–25, default 2) rather than 25, because the runtime's long-poll answers early only at rest. Best effort: a throwing `onActivity` goes to `onError` and is not retried, and `onEnded` delivery is unchanged. Not breaking: without `onActivity` the watcher behaves exactly as before. New exported type `RunActivityEvent`. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
185
185
 
186
186
  ## 0.3.397
187
187
 
188
188
  ### A runtime older than the SDK is refused before it handles the request
189
189
 
190
- The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every request except `/healthz`. The hosting router in front of a hosted runtime, and a runtime at this release or later, answer a request that names a newer release than the runtime's with `RuntimeVersionError` (`status` 426, `code` `runtime_version_unsupported`) **before any handler runs**: nothing is accepted, no `Idempotency-Key` is recorded, and a retry after the runtime is rolled forward is a first send. Previously an older runtime handled the request and the SDK raised afterwards. Not breaking: no app code changes. An app that worked around the late check, such as a `runs.list` read before every send, can delete it once its runtimes are at or after this release behind a hosting router that has the check. Back off on `RuntimeVersionError` and retry under the same key. A browser app's preflight now lists `crouter-minimum-runtime-version` in `Access-Control-Allow-Headers`. See [[crouter-sdk/errors]].
190
+ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every request except `/healthz`. The hosting router in front of a hosted runtime, and a runtime at this release or later, answer a request that names a newer release than the runtime's with `RuntimeVersionError` (`status` 426, `code` `runtime_version_unsupported`) **before any handler runs**: nothing is accepted, no `Idempotency-Key` is recorded, and a retry after the runtime is rolled forward is a first send. Previously an older runtime handled the request and the SDK raised afterwards. Not breaking: no app code changes. An app that worked around the late check, such as a `runs.list` read before every send, can delete it once its runtimes are at or after this release behind a hosting router that has the check. Back off on `RuntimeVersionError` and retry under the same key. A browser app's preflight now lists `crouter-minimum-runtime-version` in `Access-Control-Allow-Headers`. See [Errors](/docs/errors).
191
191
 
192
192
  ## 0.3.396
193
193
 
@@ -201,7 +201,7 @@ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every requ
201
201
 
202
202
  ### `OAuth2Client` owns the connection store; one `onRefusal` for a dead connection (breaking)
203
203
 
204
- `OAuth2Client` now takes the app's `ConnectionStore` once, as `store` (required), plus an optional `onRefusal(refusal)` handler, and every refused refresh goes through that one handler: a request on a connected client, `refreshIdleConnections`, and the new `RunWatcher`. Apps no longer keep their own refusal table or delete dead connections themselves. See [[crouter-sdk/client]].
204
+ `OAuth2Client` now takes the app's `ConnectionStore` once, as `store` (required), plus an optional `onRefusal(refusal)` handler, and every refused refresh goes through that one handler: a request on a connected client, `refreshIdleConnections`, and the new `RunWatcher`. Apps no longer keep their own refusal table or delete dead connections themselves. See [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
205
205
 
206
206
  - **`oauth.client(connection, store)` is now `await oauth.client(userId)`.** It loads the stored connection and returns a `ConnectedCrouter` (a `Crouter` with `userId` and `runtimeUrl`). Nothing stored throws `CrouterError` code `not_connected`. Save `exchangeCode`'s connection with `store.save(connection)` first.
207
207
  - **`oauth.refreshIdleConnections(store, {olderThanMs})` is now `oauth.refreshIdleConnections({olderThanMs})`** and returns `{refreshed, refused, failures}`. `RefreshFailure.reason` is removed: a refused connection is handled by `onRefusal`, removed, and counted in `refused`; `failures` holds only connections still stored (any other error, or a refusal whose handler threw).
@@ -215,25 +215,25 @@ The SDK now sends `Crouter-Minimum-Runtime-Version: <SDK_VERSION>` on every requ
215
215
 
216
216
  ### `RunWatcher`: learn when a background run ends (additive)
217
217
 
218
- `oauth.watchRuns({pending, onEnded, onError?, sweepMs?, concurrency?})` returns a `RunWatcher` (`start()`, `nudge()`, `stop()`) that follows the runs `pending()` reads from the app's own rows with `runs.get(wait)` and calls `onEnded({userId, runId, ended, run})` when each comes to rest (`settled`, `idle`, `waiting_on_user`) or is deleted (`gone`, only for a 404 `not_found` from the runtime, never the router). Delivery is at least once per `(runId, status, latest_sequence)`: a delivered run is re-read once per sweep and delivered again only when that key changes (an `idle` run later canceled is delivered as `settled`). A throwing `onEnded` is retried after 5 s, doubling to 5 min. Runs are opened with `oauth.client(userId)`, so a refused connection reaches `onRefusal`. There is no runtime-to-app webhook: the runtime cannot mint an app-audience token, a directory relay would add a hop, and the app's rows already make a pull durable and work on localhost. See [[crouter-sdk/client]].
218
+ `oauth.watchRuns({pending, onEnded, onError?, sweepMs?, concurrency?})` returns a `RunWatcher` (`start()`, `nudge()`, `stop()`) that follows the runs `pending()` reads from the app's own rows with `runs.get(wait)` and calls `onEnded({userId, runId, ended, run})` when each comes to rest (`settled`, `idle`, `waiting_on_user`) or is deleted (`gone`, only for a 404 `not_found` from the runtime, never the router). Delivery is at least once per `(runId, status, latest_sequence)`: a delivered run is re-read once per sweep and delivered again only when that key changes (an `idle` run later canceled is delivered as `settled`). A throwing `onEnded` is retried after 5 s, doubling to 5 min. Runs are opened with `oauth.client(userId)`, so a refused connection reaches `onRefusal`. There is no runtime-to-app webhook: the runtime cannot mint an app-audience token, a directory relay would add a hop, and the app's rows already make a pull durable and work on localhost. See [Learn when a background run ends](/docs/guides/background-runs#follow-runs-with-runwatcher).
219
219
 
220
220
  ## 0.3.394
221
221
 
222
222
  ### `grant.get` dependencies are one `{ state, message?, next }` entry per provider (breaking)
223
223
 
224
- `GrantGetDTO.dependencies[provider]` was a bare `SetupState` string; it is now a `ProviderDependency` (exported from `@north-light/sdk` and `@north-light/api`): `{ state, message?, next }`. `state` is the setup state the string used to be. `message` is the provider's own sentence for `pending` and `failed` (its `message_pending` / `message_failed`), so delete any copy of a provider's prose your app hard-codes and show `message` instead. `next` is the absolute URL of the directory's setup page for that provider and your app; link the person there (append `return_to` with your registered redirect origin) instead of building the URL yourself. **Change every read of `dependencies[p]` to `dependencies[p].state`** (or `dependencies[p]?.state` where the entry may be missing), and carry `message` and `next` into your `provider_setup_pending` details. The runtime reads only this shape from the directory's grant sync: a runtime on this release paired with a directory that still sends bare strings reports `dependencies` as `{}`, so roll the directory change with it. See [[crouter-sdk/guides/apps-on-northlight]].
224
+ `GrantGetDTO.dependencies[provider]` was a bare `SetupState` string; it is now a `ProviderDependency` (exported from `@north-light/sdk` and `@north-light/api`): `{ state, message?, next }`. `state` is the setup state the string used to be. `message` is the provider's own sentence for `pending` and `failed` (its `message_pending` / `message_failed`), so delete any copy of a provider's prose your app hard-codes and show `message` instead. `next` is the absolute URL of the directory's setup page for that provider and your app; link the person there (append `return_to` with your registered redirect origin) instead of building the URL yourself. **Change every read of `dependencies[p]` to `dependencies[p].state`** (or `dependencies[p]?.state` where the entry may be missing), and carry `message` and `next` into your `provider_setup_pending` details. The runtime reads only this shape from the directory's grant sync: a runtime on this release paired with a directory that still sends bare strings reports `dependencies` as `{}`, so roll the directory change with it. See [Waiting on a provider your app requires](/docs/guides/grant-changes#wait-on-a-provider-your-app-requires).
225
225
 
226
226
  ## 0.3.393
227
227
 
228
228
  ### The runtime orders grant state by revision; a failed grant check is `service_unavailable` (breaking)
229
229
 
230
- A runtime on this release requires the `rev` claim in every app access token, so it needs a directory that issues it; a token without `rev` is refused 401 `unauthorized`, "access token has no rev claim: the directory is older than this runtime". A freshly consented or re-consented grant is now authorized on the first call, with no wait: the runtime pulls grant state once whenever a token is newer than its mirror. **Delete any retry your app added for a 401 `grant_revoked` right after consent.** When that pull fails, the call answers 503 `service_unavailable` (`retryable: true`) instead of 401 `grant_revoked`, so a directory outage no longer looks like a refused connection. `service_unavailable` was already in `errorCodes`; app calls can now receive it. See [[crouter-sdk/client]].
230
+ A runtime on this release requires the `rev` claim in every app access token, so it needs a directory that issues it; a token without `rev` is refused 401 `unauthorized`, "access token has no rev claim: the directory is older than this runtime". A freshly consented or re-consented grant is now authorized on the first call, with no wait: the runtime pulls grant state once whenever a token is newer than its mirror. **Delete any retry your app added for a 401 `grant_revoked` right after consent.** When that pull fails, the call answers 503 `service_unavailable` (`retryable: true`) instead of 401 `grant_revoked`, so a directory outage no longer looks like a refused connection. `service_unavailable` was already in `errorCodes`; app calls can now receive it. See [An app connection and refresh](/docs/concepts/connections).
231
231
 
232
232
  ## 0.3.391
233
233
 
234
234
  ### The SDK refuses a runtime older than itself (breaking)
235
235
 
236
- `@north-light/sdk` now accepts only a runtime at or above its own version, and fails loudly otherwise instead of handing an app data its types do not describe. Every runtime answer carries `Crouter-Runtime-Version`; when that version is older than `SDK_VERSION`, or the header is absent (every runtime before this release), the call throws `RuntimeVersionError` (`code: 'runtime_version_unsupported'`, `status: 426`, `retryable: false`) whose message and `sdkVersion` / `runtimeVersion` name both versions. A refusal without the header (a router in front of the runtime answering 401 or 503) is still raised as that refusal. There is no capability negotiation: `RunObject.activity` and every other field are typed for a runtime at this release. **Upgrade every runtime the app talks to before upgrading the SDK**, and pin the SDK to an exact version. A test double that fakes the runtime with `fetch` must send `Crouter-Runtime-Version` at the SDK's version on its 2xx answers. See [[crouter-sdk/errors]].
236
+ `@north-light/sdk` now accepts only a runtime at or above its own version, and fails loudly otherwise instead of handing an app data its types do not describe. Every runtime answer carries `Crouter-Runtime-Version`; when that version is older than `SDK_VERSION`, or the header is absent (every runtime before this release), the call throws `RuntimeVersionError` (`code: 'runtime_version_unsupported'`, `status: 426`, `retryable: false`) whose message and `sdkVersion` / `runtimeVersion` name both versions. A refusal without the header (a router in front of the runtime answering 401 or 503) is still raised as that refusal. There is no capability negotiation: `RunObject.activity` and every other field are typed for a runtime at this release. **Upgrade every runtime the app talks to before upgrading the SDK**, and pin the SDK to an exact version. A test double that fakes the runtime with `fetch` must send `Crouter-Runtime-Version` at the SDK's version on its 2xx answers. See [Errors](/docs/errors).
237
237
 
238
238
  ### One release is one version set
239
239
 
@@ -261,7 +261,7 @@ A runtime on this release requires the `rev` claim in every app access token, so
261
261
 
262
262
  ### `runs.get` reports what a running run is doing (additive)
263
263
 
264
- `RunObject.activity` is `{phase: 'starting' | 'thinking' | 'writing' | 'between_turns'}` or `{phase: 'tool', tool: {name, summary}}` while the run is `running`, and `null` otherwise, so a polling app can show progress from `runs.get` without reading `runs.trace`. `runs.list` entries carry it too. See [[crouter-sdk/guides/apps-on-northlight]]. A test double that builds a `RunObject` literal must add `activity`.
264
+ `RunObject.activity` is `{phase: 'starting' | 'thinking' | 'writing' | 'between_turns'}` or `{phase: 'tool', tool: {name, summary}}` while the run is `running`, and `null` otherwise, so a polling app can show progress from `runs.get` without reading `runs.trace`. `runs.list` entries carry it too. See [Showing a background run's progress](/docs/guides/typed-results#showing-progress). A test double that builds a `RunObject` literal must add `activity`.
265
265
 
266
266
  ## 0.3.374
267
267
 
@@ -273,7 +273,7 @@ A sign-in that grants the `profile` scope returns `name`, `givenName`, and `fami
273
273
 
274
274
  ### `CrouterError.code` is typed `SdkErrorCode`
275
275
 
276
- Every code the SDK raises itself is now listed in `sdkErrorCodes` (exported with `SdkErrorCode` and `mapError` from `@north-light/sdk/errors`), and `CrouterError`'s `code` and constructor are typed with it. Runtime behavior is unchanged. **An app that constructs `new CrouterError(message, code)` with a code of its own, for example in a test double, no longer typechecks:** throw its own error class or an `APIError` instead. See [[crouter-sdk/errors]].
276
+ Every code the SDK raises itself is now listed in `sdkErrorCodes` (exported with `SdkErrorCode` and `mapError` from `@north-light/sdk/errors`), and `CrouterError`'s `code` and constructor are typed with it. Runtime behavior is unchanged. **An app that constructs `new CrouterError(message, code)` with a code of its own, for example in a test double, no longer typechecks:** throw its own error class or an `APIError` instead. See [Errors](/docs/errors).
277
277
 
278
278
  ### Run ergonomics (additive)
279
279
 
@@ -289,7 +289,7 @@ When the directory refuses a connection's refresh token, the SDK now raises the
289
289
  - `grant_suspended`: your app is paused for that person. Nothing was deleted; keep their data.
290
290
  - `refresh_token_expired`: the refresh token went 90 days unused. The grant is still active; reconnecting skips the consent page.
291
291
 
292
- Each is a 401 `AuthenticationError` with `retryable: false`, `userAction: 'reconnect'`, and `origin: 'directory'`, and the connected client from `oauth.client(...)` stops refreshing after it, as it already did for `grant_revoked` and `refresh_token_reused`. `errorCodes` and the `ErrorCode` type gain the three codes. The table of codes and app actions is in [[crouter-sdk/client]].
292
+ Each is a 401 `AuthenticationError` with `retryable: false`, `userAction: 'reconnect'`, and `origin: 'directory'`, and the connected client from `oauth.client(...)` stops refreshing after it, as it already did for `grant_revoked` and `refresh_token_reused`. `errorCodes` and the `ErrorCode` type gain the three codes. The table of codes and app actions is in [When a refresh is refused](/docs/concepts/connections#when-a-refresh-is-refused).
293
293
 
294
294
  **Behavior changes for existing apps:**
295
295
 
@@ -1,2 +1,2 @@
1
- var K=Object.defineProperty;var a=(e,o)=>K(e,"name",{value:o,configurable:!0});import{envNodeId as Y}from"../../shared/env.js";import{defineLeaf as G}from"../../core/command.js";import{InputError as n}from"../../core/io.js";import{requireScope as V}from"../../core/errors.js";import{mark as q}from"../../core/timing.js";import{OUTPUT_SCHEMA_CONSTRAINTS as X,OUTPUT_SCHEMA_TRANSPORT as Q,parseOutputSchemaValue as Z}from"../../core/runtime/structured-output.js";import{MODEL_SPEC_FORMS as ee,MODEL_SPEC_NEXT as te}from"../../core/runtime/model-selection.js";import{parseDurationMs as oe}from"../../core/wake.js";import{kindsStateBlock as ne,profilesStateBlock as re,resolveProfileViaDaemon as ie}from"../profile/daemon-profiles.js";import{cliClient as F,getNodeOrNull as ae,rethrowAsCliError as H}from"../api-client.js";import{inTmux as L}from"../../core/runtime/placement-tmux.js";import{openSpawnViewer as se}from"../surface/node/placement.js";import{randomUUID as de}from"node:crypto";import{resolve as le}from"node:path";import{isDaemonTransportApiError as $}from"../../api/errors.js";import{ExitCode as ce}from"../../types.js";const ue=1e5,z='--output-schema writes the accepted result to the node\u2019s context/result.json and pushes it as the node\u2019s final report; a node that cannot honestly satisfy the schema declines with `crtr push result --decline "<reason>" --code <token>`, which finishes it with a failure outcome (reason `declined`) whose `declined` field carries { reason, code, retryable } and no result. Name the code tokens you accept in the prompt; crouter carries the code verbatim and you validate it.',W=["A resident is told never to push a final, so the spawner gets no report wake when the work is done.","If it stops mid-task, nothing prompts it to continue.","After 15 unattended minutes it is concluded with only a deferred update, which does not wake the spawner."],B="To re-task a terminal child after its final, use `crtr node message send --reopen`; to make an existing node resident later, use `crtr node config --lifecycle resident`.",N="Do not wait, poll, or duplicate this child's assignment in your own context \u2014 there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn \u2014 the wake brings you back.";async function he(e){if(e===void 0||e==="")return N;const o=await ae(e);if(o===null||o.mode!=="orchestrator")return N;const t=o.context_tokens??null;return t===null||t<ue?N:`Child spawned \u2014 you're auto-subscribed, so its finish wakes you on its own; never wait, poll, or duplicate the child's assignment in your own context. But you're an orchestrator already carrying ~${Math.round(t/1e3)}k of context: rather than hold this window open for the result, bring your document \`roadmap\` current (\`crtr doc edit roadmap\`) and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap \u2014 let that clean revive absorb the result instead of this bloated context.`}a(he,"childFollowUp");function fe(e,o){throw $(e)||H(e),new n({error:"node_create_effect_unknown",message:`Node creation did not receive an acknowledgement after automatic recovery with request key ${o}; its effect is unknown.`,next:"Do not create a replacement node until the daemon is reachable and this request can be recovered."},ce.NETWORK)}a(fe,"createTransportFailure");function me(){return[{kind:"stdin",name:"prompt",required:!1,constraint:"First user message."},{kind:"flag",name:"kind",type:"string",required:!1,constraint:"Persona kind. Defaults to the profile's default kind; `general` when the profile has none. The <kinds> list below names every top-level installable kind."},{kind:"flag",name:"mode",type:"enum",choices:["base","orchestrator"],required:!1,default:"base",constraint:"Persona mode. base for one hands-on unit; orchestrator when the unit itself needs decomposition across children. When you hold several parallel units of one kind, spawn ONE orchestrator child of that kind to own the fan-out rather than spawning the units yourself \u2014 unless you are already an orchestrator of that kind, whose job is exactly that fan-out."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Pin the node to this directory. Without --worktree, defaults to the spawner\u2019s directory for a managed child and the invocation directory for a root. With --worktree, selects the repository used for creation, defaulting to the invocation directory; the child runs in the new checkout."},{kind:"flag",name:"name",type:"string",required:!1,constraint:"Display name."},{kind:"flag",name:"parent",type:"string",required:!1,constraint:"Parent node id. Defaults to the calling node."},{kind:"flag",name:"node-id",type:"string",required:!1,constraint:"Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present."},{kind:"flag",name:"root",type:"bool",required:!1,constraint:"Spawn an independent root with no parent or subscription. Mutually exclusive with --worktree."},{kind:"flag",name:"resident",type:"bool",required:!1,constraint:"Birth the node as resident, a conversation partner for a person rather than a worker. Omit unless the user explicitly asks for a node they can talk with back and forth.",focusedHelp:{whenToUse:"Pass it ONLY when the user explicitly asks for a node they can talk with back and forth. Never infer it from task size, from expecting the user to look at the node, or from wanting to follow up later. "+B,value:"Boolean. Roots default to resident, so with --root it changes nothing. Managed children default to terminal; for a managed child the command first refuses, explains the cost, and says how to confirm.",effects:[...W,"It ends by going dormant or by being closed."]}},{kind:"flag",name:"terminal",type:"bool",required:!1,constraint:"Birth the node as terminal: it finalizes when its work is done and leaves nothing wakeable behind. Only meaningful with --root, whose default is resident; managed children are already terminal. Pass it for bounded programmatic work no one will open a viewer on. Mutually exclusive with --resident."},{kind:"flag",name:"force",type:"bool",required:!1,default:!1,hidden:!0,constraint:"Confirm a resident managed child (--resident without --root) after the user explicitly asked for one."},{kind:"flag",name:"worktree",type:"string",required:!1,constraint:"Existing local base branch for this child\u2019s managed worktree. When invoking from a managed worktree, also pass --cwd pointing at the repository\u2019s primary checkout; keep --worktree set to the desired parent branch. Mutually exclusive with --root."},{kind:"flag",name:"fork-from",type:"string",required:!1,constraint:"Fork the new node from an existing node id, absolute session path, or partial pi session uuid."},{kind:"flag",name:"model",type:"string",required:!1,constraint:"Durable model override. Omit unless the task or governing guidance requires a specific model.",focusedHelp:{whenToUse:"Use only when the user, task, or governing agent guidance already requires a specific provider, model, capability tier, or family. Omit it for routine delegation: the selected persona kind and profile own normal model selection, and review personas may enforce a quality floor.",value:`Accepted forms: ${ee}. Concrete provider/id requests must be registered. Portable provider/tier, bare-tier, and family-alias requests resolve through the configured model ladders.`,effects:["The resolved concrete model is stored on the node and in its launch recipe.","A concrete provider/id runs exactly that model on every launch and revive. A portable provider/tier, bare tier, or family alias keeps its tier: when that model's provider is rate-limited or rejects its credential, the node moves to the next model in its route: another provider's model at the same tier first, then the configured fallback tier (strong by default).","A persona quality floor may raise a weaker request before its resolved model is stored."]}},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile override. Use only when the task needs different project purview, owned documents, or environment; otherwise omit for normal inheritance or selection.",focusedHelp:{whenToUse:"Use only when the work must run under a different profile because it needs that profile\u2019s project purview, owned documents, stored environment, or defaults. Do not use it merely to change the working directory, persona kind, model, or graph ownership; those have separate parameters.",value:"Pass a profile id or name from the <profiles> list below; a close-enough name resolves to the nearest profile and the choice is reported on stderr. Omit it inside a node to inherit the caller\u2019s profile; outside a node, omission runs normal cwd-based profile selection.",effects:["The selected profile id is stored on the node and supplies its profile-scoped config, documents, environment, and injected bearings.","It does not change the parent or subscription; --cwd separately pins the working directory."]}},{kind:"flag",name:"situational-context",type:"string",required:!1,constraint:"Hidden ambient context appended to injected bearings, never visible chat."},{kind:"flag",name:"no-kickoff",type:"bool",required:!1,hidden:!0,constraint:"With --root, start the broker and session idle without a first message or model turn. Mutually exclusive with a prompt and --mode orchestrator."},{kind:"flag",name:"deadline",type:"string",required:!1,constraint:"Positive, representable wall-clock duration from spawn. Grammar: one or more positive integer-unit parts; units: s, m, h, d; adjacent parts are summed. Omit for no deadline."},{kind:"flag",name:"outcome-action",type:"string",required:!1,constraint:"Declared human-action name that receives the node outcome. Grammar: 1\u2013128 ASCII letters, digits, dots, underscores, or hyphens; starts with a letter or digit. Omit when no external delivery target is needed."},{kind:"flag",name:"outcome-payload",type:"string",required:!1,constraint:"JSON value, serialized at most 64 KiB, frozen and echoed to --outcome-action. Requires --outcome-action."},{kind:"flag",name:"output-schema",type:"string",required:!1,constraint:"Replace prose completion with a schema-constrained final result. Use only when its consumer requires fixed fields.",focusedHelp:{whenToUse:"Use only when the node\u2019s consumer needs a machine-checkable final object with fixed fields. Omit it for ordinary delegation or when a prose report is enough; structured output replaces the completion protocol, and an over-constrained schema can force retries.",value:`Pass the JSON Schema the result must satisfy. ${Q} ${X}`,effects:["The node must answer with `crtr push result` and cannot finish until the result is accepted; a plain `crtr push final` is blocked while the request is pending.",z,"The managed parent wakes through its existing subscription when the final report arrives; an independent root has no subscriber to wake.","An accepted result, or a decline, removes the request and terminates the node."]}}]}a(me,"nodeNewParams");function pe(){return[{name:"node_id",type:"string",required:!0,constraint:"New node id."},{name:"name",type:"string",required:!0,constraint:"Display name."},{name:"frozen_at",type:"string",required:!1,constraint:"Present when a managed child exists but capacity deferred its broker start."},...L()?[{name:"window",type:"string",required:!1,constraint:"tmux window id when a viewer opens."},{name:"session",type:"string",required:!1,constraint:"tmux session when a viewer opens."}]:[],{name:"follow_up",type:"string",required:!0,constraint:"Managed child: continue unrelated work or stop; its final report wakes you. Root: hand it off; no report wakes you."}]}a(pe,"nodeNewOutput");function we(){return["Creates nodes/<id>/ in the canvas home, indexes the node in canvas.db, and starts its detached broker engine.","A managed child creates an active parent subscription; its pushes wake the parent. --root creates an independent node with spawned_by provenance and no parent subscription.","--worktree creates a managed worktree at ~/.crouter/canvas/worktrees/<node-id>/ on a new crtr/<node-id> branch.",z,"--deadline persists a wall-clock deadline from spawn; expiry cancels the node and records a failure outcome with reason deadline_exceeded.","--outcome-action registers one resolved local human-action target. When the node settles, crouter retries delivery of its outcome document until the target accepts it or permanently rejects it; --outcome-payload is frozen and echoed in that document.",...L()?["--root opens a viewer for the new node in the current tmux session."]:[]]}a(we,"nodeNewEffects");async function ke(e){V("crtr:act","crtr node new");const o=e.prompt??"",t=e.noKickoff===!0;if(!t&&o.trim()==="")throw new n({error:"empty_prompt",message:"a prompt is required (stdin or positional)",next:"Pipe a task on stdin or pass it as an argument, or pass --no-kickoff to spawn an idle node with none."});if(t&&o.trim()!=="")throw new n({error:"no_kickoff_with_prompt",message:"--no-kickoff cannot be combined with a prompt",field:"no-kickoff",next:"Drop --no-kickoff, or drop the prompt."});const l=e.kind,T=e.mode??"base",c=e.cwd?.trim(),u=c!==void 0&&c!==""?le(c):void 0,O=e.name,J=e.parent,r=e.root===!0,h=e.resident===!0,S=e.terminal===!0;if(h&&S)throw new n({error:"lifecycle_conflict",message:"--resident and --terminal cannot both be passed",field:"terminal",next:"Pass the one birth lifecycle you want, or neither to take the default (resident for --root, terminal for a managed child)."});if(h&&!r&&e.force!==!0)throw new n({error:"resident_child_requires_force",message:`--resident on a managed child makes it a conversation partner for a person, not a worker that reports back. ${W.join(" ")}`,field:"resident",next:`If the user did not explicitly ask for a node they can talk with back and forth, drop --resident; a terminal child's final report wakes you. ${B} If the user did explicitly ask for one, re-run with --force.`});const f=e.worktree;if(f!==void 0&&f.trim()==="")throw new n({error:"worktree_base_required",message:"--worktree requires a base branch",field:"worktree",next:"Pass an existing local branch that landing can fast-forward."});const m=f?.trim(),P=e.forkFrom,p=e.model?.trim();if(p==="")throw new n({error:"empty_spec",message:"a model spec is required",field:"model",next:`Run \`crtr node new --model -h\` and read the focused contract. ${te}`});const E=e.profile,D=E===void 0?void 0:(await ie(E)).id,w=e.situationalContext?.trim(),C=w!==void 0&&w!==""?w:void 0,A=Z(e.outputSchema),U=A!==null?JSON.stringify(A):null,d=e.deadline;if(d!==void 0){const s=oe(d),x=s===null?Number.NaN:new Date(Date.now()+s).getTime();if(s===null||!Number.isFinite(s)||s<=0||!Number.isFinite(x))throw new n({error:"invalid_deadline",message:"--deadline must be a positive representable duration",field:"deadline",next:"Pass positive integer-unit parts using s, m, h, or d."})}const k=e.outcomeAction,y=e.outcomePayload;if(y!==void 0&&k===void 0)throw new n({error:"outcome_payload_requires_action",message:"--outcome-payload requires --outcome-action",field:"outcome-payload",next:"Pass the declared delivery action with --outcome-action."});let g;if(y!==void 0)try{g=JSON.parse(y)}catch{throw new n({error:"invalid_outcome_payload",message:"--outcome-payload must be valid JSON",field:"outcome-payload",next:"Pass one valid JSON value."})}const v=e.nodeId?.trim();if(r&&m!==void 0)throw new n({error:"worktree_root_conflict",message:"--worktree is mutually exclusive with --root",field:"worktree",next:"Drop either --root or --worktree."});if(t&&!r)throw new n({error:"no_kickoff_requires_root",message:"--no-kickoff is only valid with --root",field:"no-kickoff",next:"Add --root, or drop --no-kickoff and provide a prompt."});if(t&&T==="orchestrator")throw new n({error:"no_kickoff_orchestrator_unsupported",message:"--no-kickoff cannot be combined with --mode orchestrator",field:"no-kickoff",next:"Drop --no-kickoff and provide a prompt, or spawn in base mode."});const b=Y(),I=J??b;q("node_new.create_start",{root:r});const R=de(),M={...l!==void 0?{kind:l}:{},mode:T,cwd:process.cwd(),...u!==void 0&&u!==""?{pin_cwd:u}:{},...O!==void 0?{name:O}:{},prompt:o,...r?{root:!0}:{parent:I??null},...b!==void 0?{creator:b}:{},...h?{root_lifecycle:"resident"}:{},...S?{root_lifecycle:"terminal"}:{},...m!==void 0?{worktree:m}:{},...P!==void 0?{fork_from:P}:{},...p!==void 0?{model:p}:{},...D!==void 0?{profile:D}:{},...C!==void 0?{situational_context:C}:{},...t?{no_kickoff:!0}:{},...U!==null?{output_schema:U}:{},...d!==void 0?{deadline:d}:{},...k!==void 0?{outcome_delivery:{action:k,...g===void 0?{}:{payload:g}}}:{},...v!==void 0&&v!==""?{node_id:v}:{},...r?{prefer_warm:!0}:{},request_key:R};let i;try{i=await F().createNode(M)}catch(s){$(s)||H(s);try{i=await F().createNode(M)}catch(x){fe(x,R)}}q("node_new.node_created",{node:i.node_id});let _={};r&&(_=se(i.node_id,i.cwd,i.name)),r&&q("node_new.viewer_window_opened");const j=!r&&i.frozen_at!==null;return{node_id:i.node_id,name:i.name,...j?{frozen_at:i.frozen_at}:{},window:_.window,session:_.session,follow_up:r?t?"Independent root spawned idle. No kickoff turn ran; hand it off or attach and prompt it. No report wakes you.":"Independent root spawned. You are not subscribed, so its finish does not wake you; hand it off and move on.":j?"Child row exists, but no broker started because capacity is full. It is frozen and will launch when a broker slot frees; its final report wakes you then.":await he(I)}}a(ke,"runNodeCreation");const Re=G({name:"new",description:"create and start a node immediately",whenToUse:"a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel (usually via one orchestrator child of the matching kind rather than a flat fan-out from you), a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose",tier:"important",help:{name:"node new",summary:"create a node immediately and start its detached broker",guide:"The prompt is the child\u2019s entire brief: it wakes with this prompt, its persona, and ambient bearings \u2014 none of your conversation carries over. State the outcome and constraints. Pass an implementation plan only when it is already settled or necessary to preserve an approved decision; otherwise, let the child investigate and choose how. Give the reason along with the request: the larger goal this serves and what the child's output enables, so the child resolves ambiguity toward your intent instead of guessing. Point at every document the task depends on (a spec, an explore map, an earlier child's findings) as `[[<node-id>/<name>]]`, or `[[name]]` for a repo, profile or user document; the child reads each with `crtr canvas read` and receives nothing the prompt does not point at. Name every file or directory as an absolute host path, never `~`-relative: the child's `~` is its own HOME, not yours, and a hook cwd outside its mounts (e.g. `/tmp`) is refused. When the report must carry something specific\u2014findings inline, or pointers to documents it wrote\u2014say so in the prompt.",params:me(),output:pe(),outputKind:"object",effects:we()},helpState:{leaf:ne,flags:{profile:re}},run:a(e=>ke(e),"run"),render:a(e=>{const o=e.window?` in window ${e.window}`:"",t=[`Spawned "${e.name}" (${e.node_id})${o}.`];return e.session&&t.push(`- session: ${e.session}`),t.push("",String(e.follow_up)),t.join(`
2
- `)},"render")});export{he as childFollowUp,Re as nodeNew};
1
+ var G=Object.defineProperty;var s=(e,n)=>G(e,"name",{value:n,configurable:!0});import{envNodeId as V}from"../../shared/env.js";import{defineLeaf as X}from"../../core/command.js";import{InputError as o}from"../../core/io.js";import{requireScope as Q}from"../../core/errors.js";import{mark as N}from"../../core/timing.js";import{OUTPUT_SCHEMA_CONSTRAINTS as Z,OUTPUT_SCHEMA_TRANSPORT as ee,parseOutputSchemaValue as te}from"../../core/runtime/structured-output.js";import{MODEL_SPEC_FORMS as oe,MODEL_SPEC_NEXT as ne}from"../../core/runtime/model-selection.js";import{parseDurationMs as re}from"../../core/wake.js";import{kindsStateBlock as ie,profilesStateBlock as ae,resolveProfileViaDaemon as se}from"../profile/daemon-profiles.js";import{cliClient as H,getNodeOrNull as de,rethrowAsCliError as $}from"../api-client.js";import{inTmux as z}from"../../core/runtime/placement-tmux.js";import{openSpawnViewer as ce}from"../surface/node/placement.js";import{randomUUID as le}from"node:crypto";import{resolve as ue}from"node:path";import{isDaemonTransportApiError as W}from"../../api/errors.js";import{ExitCode as he}from"../../types.js";const fe=1e5,B='--output-schema writes the accepted result to the node\u2019s context/result.json and pushes it as the node\u2019s final report; a node that cannot honestly satisfy the schema declines with `crtr push result --decline "<reason>" --code <token>`, which finishes it with a failure outcome (reason `declined`) whose `declined` field carries { reason, code, retryable } and no result. Name the code tokens you accept in the prompt; crouter carries the code verbatim and you validate it.',K=["A resident is told never to push a final, so the spawner gets no report wake when the work is done.","If it stops mid-task, nothing prompts it to continue.","After 15 unattended minutes it is concluded with only a deferred update, which does not wake the spawner."],J="To re-task a terminal child after its final, use `crtr node message send --reopen`; to make an existing node resident later, use `crtr node config --lifecycle resident`.",T="Do not wait, poll, or duplicate this child's assignment in your own context \u2014 there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn \u2014 the wake brings you back.";async function me(e){if(e===void 0||e==="")return T;const n=await de(e);if(n===null||n.mode!=="orchestrator")return T;const t=n.context_tokens??null;return t===null||t<fe?T:`Child spawned \u2014 you're auto-subscribed, so its finish wakes you on its own; never wait, poll, or duplicate the child's assignment in your own context. But you're an orchestrator already carrying ~${Math.round(t/1e3)}k of context: rather than hold this window open for the result, bring your document \`roadmap\` current (\`crtr doc edit roadmap\`) and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap \u2014 let that clean revive absorb the result instead of this bloated context.`}s(me,"childFollowUp");function pe(e,n){throw W(e)||$(e),new o({error:"node_create_effect_unknown",message:`Node creation did not receive an acknowledgement after automatic recovery with request key ${n}; its effect is unknown.`,next:"Do not create a replacement node until the daemon is reachable and this request can be recovered."},he.NETWORK)}s(pe,"createTransportFailure");function we(){return[{kind:"stdin",name:"prompt",required:!1,constraint:"First user message."},{kind:"flag",name:"kind",type:"string",required:!1,constraint:"Persona kind. Defaults to the profile's default kind; `general` when the profile has none. The <kinds> list below names every top-level installable kind."},{kind:"flag",name:"mode",type:"enum",choices:["base","orchestrator"],required:!1,default:"base",constraint:"Persona mode. base for one hands-on unit; orchestrator when the unit itself needs decomposition across children. When you hold several parallel units of one kind, spawn ONE orchestrator child of that kind to own the fan-out rather than spawning the units yourself \u2014 unless you are already an orchestrator of that kind, whose job is exactly that fan-out."},{kind:"flag",name:"cwd",type:"path",required:!1,constraint:"Pin the node to this directory. Without --worktree, defaults to the spawner\u2019s directory for a managed child and the invocation directory for a root. With --worktree, selects the repository used for creation, defaulting to the invocation directory; the child runs in the new checkout."},{kind:"flag",name:"name",type:"string",required:!1,constraint:"Display name."},{kind:"flag",name:"parent",type:"string",required:!1,constraint:"Parent node id. Defaults to the calling node."},{kind:"flag",name:"node-id",type:"string",required:!1,constraint:"Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present."},{kind:"flag",name:"root",type:"bool",required:!1,constraint:"Spawn an independent root with no parent or subscription. Mutually exclusive with --worktree."},{kind:"flag",name:"resident",type:"bool",required:!1,constraint:"Birth the node as resident, a conversation partner for a person rather than a worker. Omit unless the user explicitly asks for a node they can talk with back and forth.",focusedHelp:{whenToUse:"Pass it ONLY when the user explicitly asks for a node they can talk with back and forth. Never infer it from task size, from expecting the user to look at the node, or from wanting to follow up later. "+J,value:"Boolean. Roots default to resident, so with --root it changes nothing. Managed children default to terminal; for a managed child the command first refuses, explains the cost, and says how to confirm.",effects:[...K,"It ends by going dormant or by being closed."]}},{kind:"flag",name:"terminal",type:"bool",required:!1,constraint:"Birth the node as terminal: it finalizes when its work is done and leaves nothing wakeable behind. Only meaningful with --root, whose default is resident; managed children are already terminal. Pass it for bounded programmatic work no one will open a viewer on. Mutually exclusive with --resident."},{kind:"flag",name:"force",type:"bool",required:!1,default:!1,hidden:!0,constraint:"Confirm a resident managed child (--resident without --root) after the user explicitly asked for one."},{kind:"flag",name:"worktree",type:"string",required:!1,constraint:"Existing local base branch for this child\u2019s managed worktree. When invoking from a managed worktree, also pass --cwd pointing at the repository\u2019s primary checkout; keep --worktree set to the desired parent branch. Mutually exclusive with --root."},{kind:"flag",name:"fork-from",type:"string",required:!1,constraint:"Fork the new node from an existing node id, absolute session path, or partial pi session uuid."},{kind:"flag",name:"model",type:"string",required:!1,constraint:"Durable model override. Omit unless the task or governing guidance requires a specific model.",focusedHelp:{whenToUse:"Use only when the user, task, or governing agent guidance already requires a specific provider, model, capability tier, or family. Omit it for routine delegation: the selected persona kind and profile own normal model selection, and review personas may enforce a quality floor.",value:`Accepted forms: ${oe}. Concrete provider/id requests must be registered. Portable provider/tier, bare-tier, and family-alias requests resolve through the configured model ladders.`,effects:["The resolved concrete model is stored on the node and in its launch recipe.","A concrete provider/id runs exactly that model on every launch and revive. A portable provider/tier, bare tier, or family alias keeps its tier: when that model's provider is rate-limited or rejects its credential, the node moves to the next model in its route: another provider's model at the same tier first, then the configured fallback tier (strong by default).","A persona quality floor may raise a weaker request before its resolved model is stored."]}},{kind:"flag",name:"profile",type:"string",required:!1,constraint:"Profile override. Use only when the task needs different project purview, owned documents, or environment; otherwise omit for normal inheritance or selection.",focusedHelp:{whenToUse:"Use only when the work must run under a different profile because it needs that profile\u2019s project purview, owned documents, stored environment, or defaults. Do not use it merely to change the working directory, persona kind, model, or graph ownership; those have separate parameters.",value:"Pass a profile id or name from the <profiles> list below; a close-enough name resolves to the nearest profile and the choice is reported on stderr. Omit it inside a node to inherit the caller\u2019s profile; outside a node, omission runs normal cwd-based profile selection.",effects:["The selected profile id is stored on the node and supplies its profile-scoped config, documents, environment, and injected bearings.","It does not change the parent or subscription; --cwd separately pins the working directory."]}},{kind:"flag",name:"situational-context",type:"string",required:!1,constraint:"Hidden ambient context appended to injected bearings, never visible chat."},{kind:"flag",name:"no-kickoff",type:"bool",required:!1,hidden:!0,constraint:"With --root, start the broker and session idle without a first message or model turn. Mutually exclusive with a prompt and --mode orchestrator."},{kind:"flag",name:"scopes",type:"string",required:!1,constraint:"Comma-separated scope allow-list for the node and every descendant (they inherit it and cannot widen it), e.g. `crtr:llm,crtr:act,device:shell,device:messages_read`. A provider-group call (`crtr device messages send` needs `device:messages_send`) is refused with scope_missing before any request leaves the machine when the group is absent. Must be a subset of what you hold; `crtr:act` requires `crtr:llm`. Enforced on the broker route (the node's own `crtr` calls); on an unsandboxed host a same-UID process can bypass it by calling the daemon socket directly (crouter#146), so it guards against a mistaken call, not a hostile node. Keep the list short: it travels in a request header on every device call (crouter#147)."},{kind:"flag",name:"deadline",type:"string",required:!1,constraint:"Positive, representable wall-clock duration from spawn. Grammar: one or more positive integer-unit parts; units: s, m, h, d; adjacent parts are summed. Omit for no deadline."},{kind:"flag",name:"outcome-action",type:"string",required:!1,constraint:"Declared human-action name that receives the node outcome. Grammar: 1\u2013128 ASCII letters, digits, dots, underscores, or hyphens; starts with a letter or digit. Omit when no external delivery target is needed."},{kind:"flag",name:"outcome-payload",type:"string",required:!1,constraint:"JSON value, serialized at most 64 KiB, frozen and echoed to --outcome-action. Requires --outcome-action."},{kind:"flag",name:"output-schema",type:"string",required:!1,constraint:"Replace prose completion with a schema-constrained final result. Use only when its consumer requires fixed fields.",focusedHelp:{whenToUse:"Use only when the node\u2019s consumer needs a machine-checkable final object with fixed fields. Omit it for ordinary delegation or when a prose report is enough; structured output replaces the completion protocol, and an over-constrained schema can force retries.",value:`Pass the JSON Schema the result must satisfy. ${ee} ${Z}`,effects:["The node must answer with `crtr push result` and cannot finish until the result is accepted; a plain `crtr push final` is blocked while the request is pending.",B,"The managed parent wakes through its existing subscription when the final report arrives; an independent root has no subscriber to wake.","An accepted result, or a decline, removes the request and terminates the node."]}}]}s(we,"nodeNewParams");function ke(){return[{name:"node_id",type:"string",required:!0,constraint:"New node id."},{name:"name",type:"string",required:!0,constraint:"Display name."},{name:"frozen_at",type:"string",required:!1,constraint:"Present when a managed child exists but capacity deferred its broker start."},...z()?[{name:"window",type:"string",required:!1,constraint:"tmux window id when a viewer opens."},{name:"session",type:"string",required:!1,constraint:"tmux session when a viewer opens."}]:[],{name:"follow_up",type:"string",required:!0,constraint:"Managed child: continue unrelated work or stop; its final report wakes you. Root: hand it off; no report wakes you."}]}s(ke,"nodeNewOutput");function ye(){return["Creates nodes/<id>/ in the canvas home, indexes the node in canvas.db, and starts its detached broker engine.","A managed child creates an active parent subscription; its pushes wake the parent. --root creates an independent node with spawned_by provenance and no parent subscription.","--worktree creates a managed worktree at ~/.crouter/canvas/worktrees/<node-id>/ on a new crtr/<node-id> branch.",B,"--deadline persists a wall-clock deadline from spawn; expiry cancels the node and records a failure outcome with reason deadline_exceeded.","--outcome-action registers one resolved local human-action target. When the node settles, crouter retries delivery of its outcome document until the target accepts it or permanently rejects it; --outcome-payload is frozen and echoed in that document.",...z()?["--root opens a viewer for the new node in the current tmux session."]:[]]}s(ye,"nodeNewEffects");async function ge(e){Q("crtr:act","crtr node new");const n=e.prompt??"",t=e.noKickoff===!0;if(!t&&n.trim()==="")throw new o({error:"empty_prompt",message:"a prompt is required (stdin or positional)",next:"Pipe a task on stdin or pass it as an argument, or pass --no-kickoff to spawn an idle node with none."});if(t&&n.trim()!=="")throw new o({error:"no_kickoff_with_prompt",message:"--no-kickoff cannot be combined with a prompt",field:"no-kickoff",next:"Drop --no-kickoff, or drop the prompt."});const l=e.kind,O=e.mode??"base",u=e.cwd?.trim(),h=u!==void 0&&u!==""?ue(u):void 0,S=e.name,Y=e.parent,i=e.root===!0,f=e.resident===!0,P=e.terminal===!0;if(f&&P)throw new o({error:"lifecycle_conflict",message:"--resident and --terminal cannot both be passed",field:"terminal",next:"Pass the one birth lifecycle you want, or neither to take the default (resident for --root, terminal for a managed child)."});if(f&&!i&&e.force!==!0)throw new o({error:"resident_child_requires_force",message:`--resident on a managed child makes it a conversation partner for a person, not a worker that reports back. ${K.join(" ")}`,field:"resident",next:`If the user did not explicitly ask for a node they can talk with back and forth, drop --resident; a terminal child's final report wakes you. ${J} If the user did explicitly ask for one, re-run with --force.`});const m=e.worktree;if(m!==void 0&&m.trim()==="")throw new o({error:"worktree_base_required",message:"--worktree requires a base branch",field:"worktree",next:"Pass an existing local branch that landing can fast-forward."});const p=m?.trim(),E=e.forkFrom,w=e.model?.trim();if(w==="")throw new o({error:"empty_spec",message:"a model spec is required",field:"model",next:`Run \`crtr node new --model -h\` and read the focused contract. ${ne}`});const D=e.profile,C=D===void 0?void 0:(await se(D)).id,k=e.situationalContext?.trim(),A=k!==void 0&&k!==""?k:void 0,U=te(e.outputSchema),I=U!==null?JSON.stringify(U):null,d=e.deadline;if(d!==void 0){const r=re(d),q=r===null?Number.NaN:new Date(Date.now()+r).getTime();if(r===null||!Number.isFinite(r)||r<=0||!Number.isFinite(q))throw new o({error:"invalid_deadline",message:"--deadline must be a positive representable duration",field:"deadline",next:"Pass positive integer-unit parts using s, m, h, or d."})}const y=e.outcomeAction,g=e.outcomePayload;if(g!==void 0&&y===void 0)throw new o({error:"outcome_payload_requires_action",message:"--outcome-payload requires --outcome-action",field:"outcome-payload",next:"Pass the declared delivery action with --outcome-action."});let v;if(g!==void 0)try{v=JSON.parse(g)}catch{throw new o({error:"invalid_outcome_payload",message:"--outcome-payload must be valid JSON",field:"outcome-payload",next:"Pass one valid JSON value."})}const R=e.scopes,c=R===void 0?void 0:R.split(",").map(r=>r.trim()).filter(r=>r!=="");if(c!==void 0&&c.length===0)throw new o({error:"empty_scopes",message:"--scopes names no scope",field:"scopes",next:"Pass a comma-separated list such as crtr:llm,crtr:act, or omit --scopes to inherit."});const b=e.nodeId?.trim();if(i&&p!==void 0)throw new o({error:"worktree_root_conflict",message:"--worktree is mutually exclusive with --root",field:"worktree",next:"Drop either --root or --worktree."});if(t&&!i)throw new o({error:"no_kickoff_requires_root",message:"--no-kickoff is only valid with --root",field:"no-kickoff",next:"Add --root, or drop --no-kickoff and provide a prompt."});if(t&&O==="orchestrator")throw new o({error:"no_kickoff_orchestrator_unsupported",message:"--no-kickoff cannot be combined with --mode orchestrator",field:"no-kickoff",next:"Drop --no-kickoff and provide a prompt, or spawn in base mode."});const _=V(),M=Y??_;N("node_new.create_start",{root:i});const j=le(),L={...l!==void 0?{kind:l}:{},mode:O,cwd:process.cwd(),...h!==void 0&&h!==""?{pin_cwd:h}:{},...S!==void 0?{name:S}:{},prompt:n,...i?{root:!0}:{parent:M??null},..._!==void 0?{creator:_}:{},...f?{root_lifecycle:"resident"}:{},...P?{root_lifecycle:"terminal"}:{},...p!==void 0?{worktree:p}:{},...E!==void 0?{fork_from:E}:{},...w!==void 0?{model:w}:{},...C!==void 0?{profile:C}:{},...A!==void 0?{situational_context:A}:{},...t?{no_kickoff:!0}:{},...I!==null?{output_schema:I}:{},...c!==void 0?{scopes:c}:{},...d!==void 0?{deadline:d}:{},...y!==void 0?{outcome_delivery:{action:y,...v===void 0?{}:{payload:v}}}:{},...b!==void 0&&b!==""?{node_id:b}:{},...i?{prefer_warm:!0}:{},request_key:j};let a;try{a=await H().createNode(L)}catch(r){W(r)||$(r);try{a=await H().createNode(L)}catch(q){pe(q,j)}}N("node_new.node_created",{node:a.node_id});let x={};i&&(x=ce(a.node_id,a.cwd,a.name)),i&&N("node_new.viewer_window_opened");const F=!i&&a.frozen_at!==null;return{node_id:a.node_id,name:a.name,...F?{frozen_at:a.frozen_at}:{},window:x.window,session:x.session,follow_up:i?t?"Independent root spawned idle. No kickoff turn ran; hand it off or attach and prompt it. No report wakes you.":"Independent root spawned. You are not subscribed, so its finish does not wake you; hand it off and move on.":F?"Child row exists, but no broker started because capacity is full. It is frozen and will launch when a broker slot frees; its final report wakes you then.":await me(M)}}s(ge,"runNodeCreation");const je=X({name:"new",description:"create and start a node immediately",whenToUse:"a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel (usually via one orchestrator child of the matching kind rather than a flat fan-out from you), a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose",tier:"important",help:{name:"node new",summary:"create a node immediately and start its detached broker",guide:"The prompt is the child\u2019s entire brief: it wakes with this prompt, its persona, and ambient bearings \u2014 none of your conversation carries over. State the outcome and constraints. Pass an implementation plan only when it is already settled or necessary to preserve an approved decision; otherwise, let the child investigate and choose how. Give the reason along with the request: the larger goal this serves and what the child's output enables, so the child resolves ambiguity toward your intent instead of guessing. Point at every document the task depends on (a spec, an explore map, an earlier child's findings) as `[[<node-id>/<name>]]`, or `[[name]]` for a repo, profile or user document; the child reads each with `crtr canvas read` and receives nothing the prompt does not point at. Name every file or directory as an absolute host path, never `~`-relative: the child's `~` is its own HOME, not yours, and a hook cwd outside its mounts (e.g. `/tmp`) is refused. When the report must carry something specific\u2014findings inline, or pointers to documents it wrote\u2014say so in the prompt.",params:we(),output:ke(),outputKind:"object",effects:ye()},helpState:{leaf:ie,flags:{profile:ae}},run:s(e=>ge(e),"run"),render:s(e=>{const n=e.window?` in window ${e.window}`:"",t=[`Spawned "${e.name}" (${e.node_id})${n}.`];return e.session&&t.push(`- session: ${e.session}`),t.push("",String(e.follow_up)),t.join(`
2
+ `)},"render")});export{me as childFollowUp,je as nodeNew};
@@ -0,0 +1,40 @@
1
+ ---
2
+ title: CLI reference
3
+ description: The npx @north-light/sdk commands for scaffolding an app, writing its signing key and environment file, and installing the agent skill.
4
+ ---
5
+
6
+ # CLI reference
7
+
8
+ `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. An unknown command prints the list and exits 1.
9
+
10
+ | Command | What it does | Needs crtr |
11
+ |---|---|---|
12
+ | `npx @north-light/sdk init [dir] [--agent claude\|agents\|all] [--global-skill]` | Scaffold a Northlight app in an empty or new directory (server.ts, key, env file, skill), or add the SDK, a key and the skill to an existing project. Never overwrites a file. | No |
13
+ | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
14
+ | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
15
+ | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
16
+
17
+ `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr.
18
+
19
+ ## Details
20
+
21
+ ### `init`
22
+
23
+ In an empty or new directory it writes `package.json` (with `@north-light/sdk` pinned to the CLI's own exact version, plus `tsx`), `server.ts` (the quickstart server), `.gitignore` (`local/`, `node_modules/`) and `local/app.env`, and runs `keygen` into `local/app-key.json`. It installs the skill. Then it prints the public JWK, the console URL `https://account.northlight.ai/console/new`, the values to type, and `npx @north-light/sdk env --client-id <client id>` as the next step.
24
+
25
+ In a directory that already has a `package.json` it writes no app code. It runs `npm i --save-exact @north-light/sdk@<its own version>`, runs `keygen` unless `local/app-key.json` exists, and installs the skill.
26
+
27
+ It never overwrites a file. On a refused step it exits non-zero and names the file it refused.
28
+
29
+ ### `skill`
30
+
31
+ Writes the `northlight` Agent Skill that ships in the package, replacing an older copy. `--agent` picks `.claude/skills`, `.agents/skills` or both. `--global` writes under your home directory instead of the project. The skill's `metadata.sdk-version` is the package version, so run `skill` again after you change the pinned SDK version.
32
+
33
+ ### `keygen`
34
+
35
+ Writes a private signing key to `local/app-key.json` with mode `0600`, refuses to overwrite an existing file (pass `--out` for another path), and prints the public JWK on stdout. Register the JWK in the console with **Add key** on the app's **Sign-in** tab, or paste it under **Register an app**. With `--app <client id>`, it offers to add the key to your app through your own `crtr` (`crtr cloud apps keys add`); `--yes` skips the question. This needs a joined crtr, which this site does not otherwise require.
36
+
37
+ ### `env`
38
+
39
+ `env --client-id <client id>` writes `CROUTER_ISSUER` (default `https://account.northlight.ai`), `CROUTER_CLIENT_ID` and `APP_ORIGIN` (default: the existing value in the file, else `http://localhost:3000`) into `local/app.env`. It keeps every other line and makes no network call. `--out <path>` writes another file. `env --app <client id>` reads the same values through crtr instead. Passing both `--client-id` and `--app` is a usage error.
40
+
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: Use your coding agent
3
+ description: What npx @north-light/sdk init and skill install for Claude Code, Codex, Cursor and Copilot, where the skill lives, and how it stays in step with your SDK version.
4
+ ---
5
+
6
+ # Use your coding agent
7
+
8
+ The `northlight` skill teaches a coding agent the whole path in the [quick setup](/docs/quickstart): the commands, the console fields, the code patterns and the rules. `npx @north-light/sdk init` installs it into your project. The skill needs no account and no crtr.
9
+
10
+ ## Install it
11
+
12
+ For a new app, `init` installs it with everything else:
13
+
14
+ ```bash
15
+ npx @north-light/sdk@latest init my-app
16
+ ```
17
+
18
+ For a project that already has a `package.json`, run `npx @north-light/sdk init` in it. It adds `@north-light/sdk` at an exact version, makes a key if `local/app-key.json` does not exist, installs the skill, and writes no app code. It never overwrites a file: if one is in the way, it names the file and exits non-zero.
19
+
20
+ To install only the skill, or to replace an older copy:
21
+
22
+ ```bash
23
+ npx @north-light/sdk skill
24
+ ```
25
+
26
+ `skill` always replaces the `northlight` skill it finds. `--print` writes the skill to stdout instead of to files.
27
+
28
+ ## Where it goes
29
+
30
+ | Agent | In the project (default) | With `--global` |
31
+ |---|---|---|
32
+ | Codex, Cursor, GitHub Copilot, VS Code | `.agents/skills/northlight/SKILL.md` | `~/.agents/skills/northlight/SKILL.md` |
33
+ | Claude Code | `.claude/skills/northlight/SKILL.md` | `~/.claude/skills/northlight/SKILL.md` |
34
+
35
+ `--agent claude`, `--agent agents` or `--agent all` (the default) picks which. `all` writes both places. Cursor and Copilot also read `.claude/skills`, so they may list the skill twice. Use `--agent agents` or `--agent claude` to write one place. `init` takes `--global-skill` where `skill` takes `--global`.
36
+
37
+ The project copy is the default so it is committed with the project, and teammates get it from git. It matches the SDK version your project pins.
38
+
39
+ Checked: the files `init` writes, and that `skill` rewrites both, from the SDK source built on this branch. Which agent loads which directory is from each agent's documentation and is not yet checked on production for every agent.
40
+
41
+ ## Without a project: the hosted skill
42
+
43
+ An agent that has no project yet can read the hosted copy:
44
+
45
+ > Read https://sdk.docs.northlight.ai/SKILL.md and follow it.
46
+
47
+ The agent runs `npx @north-light/sdk@latest init` and then follows the quick setup. It asks you to do the browser steps: sign up, register the app at `https://account.northlight.ai/console/new`, and add permissions. The hosted copy matches the SDK version the docs site was last built from.
48
+
49
+ ## How it stays current
50
+
51
+ The skill's frontmatter carries `metadata.sdk-version`. Its first instruction tells the agent to compare that number with `node_modules/@north-light/sdk/package.json`. If they differ, the agent runs `npx @north-light/sdk skill`, which rewrites the skill from the installed package. A skill therefore always documents the SDK your project pins, and a newer SDK never gets documented against an older one.
52
+
53
+ ## Example prompts
54
+
55
+ After `init`:
56
+
57
+ > Add a "Summarize my day" button to the app. It starts a run on the signed-in person's runtime with `runs.start`, waits with `runs.wait`, and shows the report. Use the northlight skill.
58
+
59
+ Starting from nothing:
60
+
61
+ > Read https://sdk.docs.northlight.ai/SKILL.md and build me an app where people sign in with Northlight and the app texts them a daily summary. Tell me which console steps I need to do.
62
+
63
+ ## Rules the skill gives your agent
64
+
65
+ Your agent reads them from the skill. They are worth knowing as the person reviewing its work:
66
+
67
+ - Pin the exact SDK version.
68
+ - Take scope strings from the console's Add permissions list, never from memory.
69
+ - Never print, paste or commit `local/app-key.json`.
70
+ - Reach a person's runtime with `oauth.client(userId)`, not `new Crouter()`.
71
+ - Use a persistent `ConnectionStore` in production.
72
+ - Get data back with `runs.parse`, not by reading `runs.trace`.
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: Apps and sign-in
3
+ description: What registering an app creates, how an app proves who it is with a key pair, and the authorization-code flow with PKCE that OAuth2Client runs.
4
+ ---
5
+
6
+ # Apps and sign-in
7
+
8
+ An app is a registration in the Northlight directory: a client ID, a public signing key, the redirect URIs people return to after sign-in, and the list of scopes the app may ask for. You create it in the console at `https://account.northlight.ai/console/new`. Your server proves it is that app by signing its requests with the matching private key.
9
+
10
+ ## What registration holds
11
+
12
+ | Part | What it is |
13
+ |---|---|
14
+ | **Client ID** | The app's id. The console shows it after you register. Your code passes it as `clientId`. |
15
+ | **Origin URL** | Where your app runs. People see its domain as your app's maker. `http://localhost:<port>` is accepted for development. |
16
+ | **Redirect URIs** | The only places sign-in may return to. A URI must match exactly: `localhost` and `127.0.0.1` are different, and the port counts. `http://localhost`, `http://127.0.0.1` and `http://[::1]` are accepted over plain http; every other URI is `https`. |
17
+ | **Public key** | The half of your key pair the directory keeps. |
18
+ | **Scopes** | What the app may ask for. See [Scopes and grants](/docs/concepts/scopes-and-grants). |
19
+
20
+ Name, logo and "Why your app needs access" appear on the consent page.
21
+
22
+ ## A key pair, not a client secret
23
+
24
+ An app has no client secret. It signs a short assertion with its private key (`private_key_jwt`, the only token authentication method the directory supports) each time it exchanges a code or refreshes a token. The directory checks the signature against the public keys it holds.
25
+
26
+ - `npx @north-light/sdk keygen` writes the private key to `local/app-key.json` (mode `0600`) and prints the public key as JSON. `init` runs it for you. Paste the public key into **Public key** when you register, or under **Add key** on the app's **Sign-in** tab.
27
+ - The private key stays on your server. Never paste it, log it or commit it. The console refuses a pasted private key.
28
+ - An app holds at most 2 active keys, so you can add a new one beside the old one, switch, and retire the old one without downtime.
29
+ - Instead of pasting keys you can give the directory a **JWKS URL** (**Use a JWKS URL instead** on the **Sign-in** tab).
30
+ - Until a key is added, `exchangeCode` throws an `AuthenticationError`.
31
+
32
+ ## The sign-in flow
33
+
34
+ `OAuth2Client` runs the OAuth 2.0 authorization-code flow with PKCE (`S256`) and an OpenID Connect nonce. Your two routes are short:
35
+
36
+ <include lang="ts">../../../examples/apps/quickstart-server.ts#login</include>
37
+
38
+ `authorizeUrl` returns the URL to redirect to, plus three values to keep for this sign-in: `codeVerifier`, `state` and `nonce`. The example keeps them in a `Map` keyed by `state`. Use your own session store.
39
+
40
+ <include lang="ts">../../../examples/apps/quickstart-server.ts#callback</include>
41
+
42
+ On the callback:
43
+
44
+ 1. Read `state` and `code` from the query. Find what you saved for that `state`. If nothing is there, send the person back to the login route.
45
+ 2. Call `exchangeCode` with the code, the saved `codeVerifier` and `nonce`, the `state` from the query as `state`, and the value you saved as `expectedState`. Never pass the callback's `state` for both. A missing or different `expectedState` throws `CrouterError` with code `oauth_state_mismatch` before the code is sent anywhere.
46
+ 3. `exchangeCode` verifies the ID token (ES256 signature, issuer, audience and nonce) when it has one, and returns a `Connection`. Save it in your [`ConnectionStore`](/docs/concepts/connections).
47
+
48
+ Then set your own session for the person. The SDK does not manage your sessions.
49
+
50
+ ### Scopes decide what comes back
51
+
52
+ A sign-in that asks only for `openid`, optionally with `email` and `profile`, creates no connection. Finish it with `oauth.exchangeIdentity(...)`, which returns the verified `Identity` (`sub`, and `email`, `name` and `picture` when approved and present). A sign-in that asks for runtime scopes such as `crtr:llm` finishes with `exchangeCode`. Each method refuses the other's answer with `oauth_exchange_mismatch`.
53
+
54
+ ### When the directory refuses to sign someone in
55
+
56
+ If the app is in testing and the person is not a tester, the directory redirects to your callback with `error=access_denied` and `error_description=app_in_testing` instead of a `code`. `authorizationRefusalOf(url)` reads it. See [Testing mode](/docs/concepts/testing-mode).
57
+
58
+ ## Keep sign-in secrets out of HTTP logs
59
+
60
+ The callback URL carries a one-use `code` and your `state`, and the response that follows carries a session cookie. Request loggers such as `pino-http` log full URLs and response headers by default. Log the method, the path without the query string, an id and the response status, for every route, not only the callback. Redacting only the `authorization` and `cookie` request headers does not cover these values.
61
+
62
+ ```ts
63
+ LoggerModule.forRoot({
64
+ pinoHttp: {
65
+ serializers: {
66
+ req: (req: { method?: string; url?: string; id?: unknown }) => ({
67
+ method: req.method,
68
+ path: req.url?.split('?')[0],
69
+ id: req.id,
70
+ }),
71
+ res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode }),
72
+ },
73
+ redact: ['req.headers.authorization', 'req.headers.cookie'],
74
+ },
75
+ });
76
+ ```
77
+
78
+ Test it: send a callback with a recognizable fake code and assert that the code, the state and the cookie never appear in the log output.
79
+
80
+ ## Checked and not checked
81
+
82
+ Checked on production: the discovery document lists `S256` and `private_key_jwt`, and the login route of the quickstart server redirects to `https://account.northlight.ai/authorize` with the PKCE challenge and nonce. The rest is from the SDK and directory source. A full sign-in with a real account is not yet checked on production.