@north-light/cli 0.3.414 → 0.3.416

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 (112) hide show
  1. package/dist/api/client.d.ts +20 -0
  2. package/dist/api/client.js +3 -3
  3. package/dist/api/dto/common.d.ts +1 -1
  4. package/dist/api/dto/crons.d.ts +7 -1
  5. package/dist/api/dto/human-requests.d.ts +1 -1
  6. package/dist/api/dto/run-events.d.ts +1 -1
  7. package/dist/api/dto/usage.d.ts +27 -0
  8. package/dist/api/dto/usage.js +0 -0
  9. package/dist/api/errors.js +1 -1
  10. package/dist/api/index.d.ts +3 -2
  11. package/dist/api/index.js +1 -1
  12. package/dist/builtin-memory/crouter-sdk/README.md +22 -4
  13. package/dist/builtin-memory/crouter-sdk/client.md +32 -1
  14. package/dist/builtin-memory/crouter-sdk/errors.md +3 -0
  15. package/dist/builtin-memory/crouter-sdk/getting-started.md +26 -4
  16. package/dist/builtin-memory/crouter-sdk/release-notes.md +23 -0
  17. package/dist/builtin-memory/crouter-sdk/testing.md +9 -3
  18. package/dist/builtin-memory/internal/nodes-and-canvas.md +4 -0
  19. package/dist/cli.js +7 -7
  20. package/dist/clients/attach/viewer.js +606 -606
  21. package/dist/commands/cron.js +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/core/canvas/crons.d.ts +15 -1
  24. package/dist/core/canvas/crons.js +13 -13
  25. package/dist/core/canvas/delivery-contract.d.ts +1 -1
  26. package/dist/core/canvas/migrations.js +73 -66
  27. package/dist/core/canvas/types.d.ts +1 -1
  28. package/dist/core/command.d.ts +7 -0
  29. package/dist/core/command.js +9 -9
  30. package/dist/core/grants/mirror.d.ts +4 -0
  31. package/dist/core/grants/mirror.js +2 -2
  32. package/dist/core/grants/on-sync.js +1 -1
  33. package/dist/core/graph/custom.js +5 -5
  34. package/dist/core/graph/reader.js +3 -3
  35. package/dist/core/graph/spaces/app.js +1 -1
  36. package/dist/core/graph/types.d.ts +5 -0
  37. package/dist/core/layout.d.ts +2 -0
  38. package/dist/core/layout.js +2 -2
  39. package/dist/core/llm/run.d.ts +5 -1
  40. package/dist/core/llm/run.js +3 -3
  41. package/dist/core/providers/call.d.ts +2 -0
  42. package/dist/core/providers/call.js +1 -1
  43. package/dist/core/providers/errors.js +1 -1
  44. package/dist/core/providers/receipts.d.ts +2 -0
  45. package/dist/core/providers/receipts.js +2 -3
  46. package/dist/core/runs/confinement.d.ts +2 -0
  47. package/dist/core/runs/confinement.js +2 -2
  48. package/dist/core/runs/operations.d.ts +2 -0
  49. package/dist/core/runs/operations.js +4 -4
  50. package/dist/core/runtime/broker/model-adapter.js +2 -2
  51. package/dist/core/runtime/run-command.d.ts +10 -0
  52. package/dist/core/runtime/run-command.js +1 -1
  53. package/dist/core/spaces/mounts.d.ts +7 -0
  54. package/dist/core/spaces/mounts.js +1 -1
  55. package/dist/core/spaces/process.d.ts +22 -2
  56. package/dist/core/spaces/process.js +1 -1
  57. package/dist/core/usage/ledger.d.ts +49 -0
  58. package/dist/core/usage/ledger.js +10 -0
  59. package/dist/daemon/api/app-listener.js +12 -12
  60. package/dist/daemon/api/handlers/crons.js +1 -1
  61. package/dist/daemon/api/handlers/files.js +1 -1
  62. package/dist/daemon/api/handlers/llm.d.ts +8 -0
  63. package/dist/daemon/api/handlers/llm.js +3 -3
  64. package/dist/daemon/api/handlers/nodes.js +1 -1
  65. package/dist/daemon/api/handlers/providers.js +1 -1
  66. package/dist/daemon/api/handlers/runs.js +1 -1
  67. package/dist/daemon/api/map.d.ts +1 -1
  68. package/dist/daemon/api/map.js +1 -1
  69. package/dist/daemon/api/operations.d.ts +1 -1
  70. package/dist/daemon/api/operations.js +1 -1
  71. package/dist/daemon/api/router.d.ts +15 -0
  72. package/dist/daemon/api/router.js +1 -1
  73. package/dist/daemon/api/server.d.ts +5 -1
  74. package/dist/daemon/api/server.js +1 -1
  75. package/dist/daemon/cron/armed-context.d.ts +21 -4
  76. package/dist/daemon/cron/armed-context.js +1 -1
  77. package/dist/daemon/cron/authority.d.ts +27 -0
  78. package/dist/daemon/cron/authority.js +1 -0
  79. package/dist/daemon/cron/disposition.d.ts +2 -0
  80. package/dist/daemon/cron/disposition.js +1 -1
  81. package/dist/daemon/cron/escalation.js +3 -3
  82. package/dist/daemon/cron/folders.d.ts +5 -0
  83. package/dist/daemon/cron/folders.js +1 -0
  84. package/dist/daemon/cron/lease-recovery.js +1 -1
  85. package/dist/daemon/cron/live-runs.d.ts +28 -0
  86. package/dist/daemon/cron/live-runs.js +1 -1
  87. package/dist/daemon/cron/run-socket.d.ts +30 -0
  88. package/dist/daemon/cron/run-socket.js +1 -0
  89. package/dist/daemon/cron/settle.d.ts +10 -0
  90. package/dist/daemon/cron/settle.js +1 -1
  91. package/dist/daemon/cron/sinks.d.ts +2 -1
  92. package/dist/daemon/cron/sinks.js +2 -2
  93. package/dist/daemon/cron-run.js +2 -1
  94. package/dist/daemon/crtrd.js +7 -7
  95. package/dist/daemon/reconcilers/action-lane.js +1 -1
  96. package/dist/daemon/reconcilers/broker-supervision.d.ts +10 -0
  97. package/dist/daemon/reconcilers/broker-supervision.js +4 -4
  98. package/dist/daemon/reconcilers/cron-lane.d.ts +1 -0
  99. package/dist/daemon/reconcilers/cron-lane.js +1 -1
  100. package/dist/shared/env.d.ts +6 -5
  101. package/dist/shared/env.js +2 -2
  102. package/docs/sdk/README.md +22 -4
  103. package/docs/sdk/client.md +32 -1
  104. package/docs/sdk/errors.md +3 -0
  105. package/docs/sdk/getting-started.md +26 -4
  106. package/docs/sdk/release-notes.md +23 -0
  107. package/docs/sdk/testing.md +9 -3
  108. package/package.json +2 -2
  109. package/packages/crouter-identity/dist/index.d.ts +29 -2
  110. package/packages/crouter-identity/dist/index.js +81 -12
  111. package/packages/crouter-identity/package.json +1 -1
  112. package/runtime.lock.json +12 -12
@@ -1 +1 @@
1
- var l=Object.defineProperty;var t=(c,r)=>l(c,"name",{value:r,configurable:!0});import{emitEvent as i}from"../../core/events/emit.js";import{operationIdContext as n}from"../../core/events/operation-id.js";import{recoverStaleCronLeases as a,runDueCrons as s}from"../cron-run.js";class h{static{t(this,"CronLaneReconciler")}run(r,o){if(n.fresh(()=>{try{a(r,{register:t(e=>o.lifecycle.registerDetached(e),"register")})}catch(e){i({level:"error",event:"cron.lease_recovery.failed",error:e})}}),!!o.lifecycle.acceptsDetachedWork())try{s(r,{register:t(e=>o.lifecycle.registerDetached(e),"register")})}catch(e){i({level:"error",event:"cron.run.failed",error:e})}}}export{h as CronLaneReconciler};
1
+ var i=Object.defineProperty;var o=(c,r)=>i(c,"name",{value:r,configurable:!0});import{emitEvent as t}from"../../core/events/emit.js";import{operationIdContext as a}from"../../core/events/operation-id.js";import{sweepOrphanCronFolders as n}from"../cron/folders.js";import{reapRebuiltCronExecutions as s}from"../cron/run-socket.js";import{recoverStaleCronLeases as f,runDueCrons as p}from"../cron-run.js";class C{static{o(this,"CronLaneReconciler")}lastFolderSweep=0;run(r,l){a.fresh(()=>{try{f(r,{register:o(e=>l.lifecycle.registerDetached(e),"register")})}catch(e){t({level:"error",event:"cron.lease_recovery.failed",error:e})}});try{s()}catch(e){t({level:"error",event:"cron.execution.reap_failed",error:e})}if(r-this.lastFolderSweep>=6e4){this.lastFolderSweep=r;try{n()}catch(e){t({level:"error",event:"cron.folder.sweep_failed",error:e})}}if(l.lifecycle.acceptsDetachedWork())try{p(r,{register:o(e=>l.lifecycle.registerDetached(e),"register")})}catch(e){t({level:"error",event:"cron.run.failed",error:e})}}}export{C as CronLaneReconciler};
@@ -1,12 +1,13 @@
1
1
  /** The current node's id (`CRTR_NODE_ID`), or undefined outside a node. */
2
2
  export declare function envNodeId(): string | undefined;
3
3
  /** Find the single launch snapshot mounted by the launcher into this process's
4
- * Linux namespace. Cron bash has no CRTR_NODE_ID, but its creator's mounted
5
- * launch snapshot supplies config and granted-command projection without
6
- * claiming the cron is a node. Its broker relay may already have closed when
7
- * the cron fires; still classify the sandbox so CLI bootstrap never falls back
8
- * to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
4
+ * Linux namespace. Cron bash has no CRTR_NODE_ID: its own execution folder
5
+ * (`cron-<execution_id>`, holding `cron-launch.json` and the execution socket)
6
+ * supplies config and granted-command projection without claiming the cron is
7
+ * a node, and never falls back to host stores or person authority. Neither CRTR_NODE_ID, CRTR_ANCHOR, nor
9
8
  * the caller-editable CRTR_API_SOCKET selects the launch identity. */
9
+ /** The launch snapshot a mounted run folder carries: a node's `broker-launch.json`, or a cron execution's `cron-launch.json`. */
10
+ export declare function launchSnapshotFile(dir: string): string | undefined;
10
11
  export declare function sandboxCliNodeId(): string | undefined;
11
12
  /** A Linux app command uses its mounted launch snapshot, not host user stores. */
12
13
  export declare function isSandboxedNode(): boolean;
@@ -1,2 +1,2 @@
1
- var _=Object.defineProperty;var e=(n,r)=>_(n,"name",{value:r,configurable:!0});import{lstatSync as R,readFileSync as p,readdirSync as T}from"node:fs";import{join as i}from"node:path";import{covers as E}from"@north-light/identity";function U(){return process.env.CRTR_NODE_ID}e(U,"envNodeId");function d(){if(process.platform==="linux")try{const n="/run/crtr/nodes",r=new Set(p("/proc/self/mountinfo","utf8").split(`
2
- `).map(s=>s.split(" ")[4])),t=T(n,{withFileTypes:!0}).filter(s=>s.isDirectory()&&/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(s.name)&&r.has(i(n,s.name))&&R(i(n,s.name,"broker-launch.json"),{throwIfNoEntry:!1})?.isFile());return t.length===1?t[0].name:void 0}catch{return}}e(d,"sandboxCliNodeId");function L(){return d()!==void 0}e(L,"isSandboxedNode");function P(){return process.env.CRTR_GRANTEE}e(P,"envGrantee");const f="CRTR_INBOX_BETWEEN_TURNS";function W(){return process.env[f]==="1"}e(W,"envInboxBetweenTurns");function F(){const n=process.env.CRTR_SCOPES;return n===void 0?null:n===""?[]:n.split(",")}e(F,"envScopes");function H(n,r){return n==null?!0:n.some(t=>E(t,r))}e(H,"scopeAllowed");function G(){return process.env.CRTR_EXECUTION_ID}e(G,"envExecutionId");function K(){return process.env.CRTR_PROFILE_ID}e(K,"envProfileId");function h(){return process.env.CRTR_NODE_CWD}e(h,"envNodeCwd");function b(){return process.env.CRTR_MODEL_INTENT}e(b,"envModelIntent");function k(){return process.env.CRTR_MODEL_EXACT==="1"}e(k,"envModelExact");function g(){return process.env.CRTR_HOME}e(g,"envHomeOverride");function V(){return process.env.CRTR_RUNTIME_HOME}e(V,"envRuntimeHomeOverride");function o(n,r){if(n===void 0)return r;const t=Number(n);return Number.isFinite(t)&&t>0?t:r}e(o,"parsePositiveMs");const v=5*6e4;function w(){return o(process.env.CRTR_STREAM_WATCHDOG_MS,v)}e(w,"envStreamWatchdogMs");const C=800;function y(){return o(process.env.CRTR_WATCHER_TICK_MS,C)}e(y,"envWatcherTickMs");const A=6e4;function X(){return o(process.env.CRTR_WATCHER_BACKSTOP_MS,A)}e(X,"envWatcherBackstopMs");const a=1e3;function Y(){return o(process.env.CRTR_WATCHER_DEBOUNCE_MS,a)}e(Y,"envWatcherDebounceMs");const M=6e4;function j(){return o(process.env.CRTR_RECAP_IDLE_MS,M)}e(j,"envRecapIdleMs");function z(){const n=process.env.CRTR_RECAP_MODEL;return n!==void 0&&n.trim()!==""?n.trim():void 0}e(z,"envRecapModelOverride");const S=300*1e3;function Z(){return o(process.env.CRTR_BASH_VALVE_MS,S)}e(Z,"envBashValveMs");const N=60*1e3;function $(){return o(process.env.CRTR_BASH_AUTO_BG_DEADLINE_MS,N)}e($,"envBashAutoBgDeadlineMs");function q(){const n=process.env.CRTR_PIDFILE;return n!==void 0&&n!==""?n:void 0}e(q,"envPidfileOverride");const x="@earendil-works/pi-coding-agent";function J(){return process.env.CRTR_BROKER_ENGINE??x}e(J,"envBrokerEngine");function Q(){return process.env.CRTR_PI_BINARY}e(Q,"envPiBinaryOverride");function ee(){return process.env.CRTR_NO_DAEMON_AUTOSTART==="1"}e(ee,"envNoDaemonAutostart");function ne(){return process.env.CRTRD_TCP}e(ne,"envCrtrdTcp");function re(){const n=process.env.CRTRD_TOKEN;return n!==void 0&&n!==""?n:void 0}e(re,"envCrtrdToken");function te(){return process.env.CRTR_NO_EXPORTS==="1"}e(te,"envNoExports");function oe(){return process.env.CRTR_DEBUG==="1"}e(oe,"envDebug");function c(n){if(n===void 0||n==="")return;const r=Number(n);return Number.isSafeInteger(r)&&r>=1?r:void 0}e(c,"parsePositiveInteger");function se(){return c(process.env.CRTR_MAX_LIVE_BROKERS)}e(se,"envMaxLiveBrokers");function ue(){return c(process.env.CRTR_WARN_LIVE_BROKERS)}e(ue,"envWarnLiveBrokers");function u(n){if(n===void 0)return;const r=Number(n);return Number.isFinite(r)&&r>0?r:void 0}e(u,"parsePositiveMsNoDefault");function ie(){return u(process.env.CRTR_TEST_UNATTENDED_PARK_MS)}e(ie,"envTestUnattendedParkMs");function ce(){return u(process.env.CRTR_UNATTENDED_PARK_MS)}e(ce,"envUnattendedParkMs");function D(){return u(process.env.CRTR_TEST_PARK_SUMMARY_GRACE_MS)}e(D,"envTestParkSummaryGraceMs");const O=5*6e4;function _e(){return D()??O}e(_e,"parkSummaryGraceMs");export{f as INBOX_BETWEEN_TURNS_ENV,$ as envBashAutoBgDeadlineMs,Z as envBashValveMs,J as envBrokerEngine,ne as envCrtrdTcp,re as envCrtrdToken,oe as envDebug,G as envExecutionId,P as envGrantee,g as envHomeOverride,W as envInboxBetweenTurns,se as envMaxLiveBrokers,k as envModelExact,b as envModelIntent,ee as envNoDaemonAutostart,te as envNoExports,h as envNodeCwd,U as envNodeId,Q as envPiBinaryOverride,q as envPidfileOverride,K as envProfileId,j as envRecapIdleMs,z as envRecapModelOverride,V as envRuntimeHomeOverride,F as envScopes,w as envStreamWatchdogMs,D as envTestParkSummaryGraceMs,ie as envTestUnattendedParkMs,ce as envUnattendedParkMs,ue as envWarnLiveBrokers,X as envWatcherBackstopMs,Y as envWatcherDebounceMs,y as envWatcherTickMs,L as isSandboxedNode,_e as parkSummaryGraceMs,d as sandboxCliNodeId,H as scopeAllowed};
1
+ var _=Object.defineProperty;var e=(n,r)=>_(n,"name",{value:r,configurable:!0});import{lstatSync as R,readFileSync as p,readdirSync as T}from"node:fs";import{join as u}from"node:path";import{covers as E}from"@north-light/identity";function L(){return process.env.CRTR_NODE_ID}e(L,"envNodeId");function f(n){for(const r of["broker-launch.json","cron-launch.json"]){const t=u(n,r);if(R(t,{throwIfNoEntry:!1})?.isFile())return t}}e(f,"launchSnapshotFile");function d(){if(process.platform==="linux")try{const n="/run/crtr/nodes",r=new Set(p("/proc/self/mountinfo","utf8").split(`
2
+ `).map(s=>s.split(" ")[4])),t=T(n,{withFileTypes:!0}).filter(s=>s.isDirectory()&&/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(s.name)&&r.has(u(n,s.name))&&f(u(n,s.name))!==void 0);return t.length===1?t[0].name:void 0}catch{return}}e(d,"sandboxCliNodeId");function P(){return d()!==void 0}e(P,"isSandboxedNode");function F(){return process.env.CRTR_GRANTEE}e(F,"envGrantee");const v="CRTR_INBOX_BETWEEN_TURNS";function W(){return process.env[v]==="1"}e(W,"envInboxBetweenTurns");function h(){const n=process.env.CRTR_SCOPES;return n===void 0?null:n===""?[]:n.split(",")}e(h,"envScopes");function H(n,r){return n==null?!0:n.some(t=>E(t,r))}e(H,"scopeAllowed");function G(){return process.env.CRTR_EXECUTION_ID}e(G,"envExecutionId");function K(){return process.env.CRTR_PROFILE_ID}e(K,"envProfileId");function b(){return process.env.CRTR_NODE_CWD}e(b,"envNodeCwd");function k(){return process.env.CRTR_MODEL_INTENT}e(k,"envModelIntent");function g(){return process.env.CRTR_MODEL_EXACT==="1"}e(g,"envModelExact");function V(){return process.env.CRTR_HOME}e(V,"envHomeOverride");function w(){return process.env.CRTR_RUNTIME_HOME}e(w,"envRuntimeHomeOverride");function o(n,r){if(n===void 0)return r;const t=Number(n);return Number.isFinite(t)&&t>0?t:r}e(o,"parsePositiveMs");const C=5*6e4;function y(){return o(process.env.CRTR_STREAM_WATCHDOG_MS,C)}e(y,"envStreamWatchdogMs");const A=800;function X(){return o(process.env.CRTR_WATCHER_TICK_MS,A)}e(X,"envWatcherTickMs");const a=6e4;function j(){return o(process.env.CRTR_WATCHER_BACKSTOP_MS,a)}e(j,"envWatcherBackstopMs");const M=1e3;function Y(){return o(process.env.CRTR_WATCHER_DEBOUNCE_MS,M)}e(Y,"envWatcherDebounceMs");const S=6e4;function z(){return o(process.env.CRTR_RECAP_IDLE_MS,S)}e(z,"envRecapIdleMs");function Z(){const n=process.env.CRTR_RECAP_MODEL;return n!==void 0&&n.trim()!==""?n.trim():void 0}e(Z,"envRecapModelOverride");const x=300*1e3;function $(){return o(process.env.CRTR_BASH_VALVE_MS,x)}e($,"envBashValveMs");const N=60*1e3;function q(){return o(process.env.CRTR_BASH_AUTO_BG_DEADLINE_MS,N)}e(q,"envBashAutoBgDeadlineMs");function J(){const n=process.env.CRTR_PIDFILE;return n!==void 0&&n!==""?n:void 0}e(J,"envPidfileOverride");const D="@earendil-works/pi-coding-agent";function Q(){return process.env.CRTR_BROKER_ENGINE??D}e(Q,"envBrokerEngine");function ee(){return process.env.CRTR_PI_BINARY}e(ee,"envPiBinaryOverride");function ne(){return process.env.CRTR_NO_DAEMON_AUTOSTART==="1"}e(ne,"envNoDaemonAutostart");function re(){return process.env.CRTRD_TCP}e(re,"envCrtrdTcp");function te(){const n=process.env.CRTRD_TOKEN;return n!==void 0&&n!==""?n:void 0}e(te,"envCrtrdToken");function oe(){return process.env.CRTR_NO_EXPORTS==="1"}e(oe,"envNoExports");function se(){return process.env.CRTR_DEBUG==="1"}e(se,"envDebug");function c(n){if(n===void 0||n==="")return;const r=Number(n);return Number.isSafeInteger(r)&&r>=1?r:void 0}e(c,"parsePositiveInteger");function ue(){return c(process.env.CRTR_MAX_LIVE_BROKERS)}e(ue,"envMaxLiveBrokers");function ie(){return c(process.env.CRTR_WARN_LIVE_BROKERS)}e(ie,"envWarnLiveBrokers");function i(n){if(n===void 0)return;const r=Number(n);return Number.isFinite(r)&&r>0?r:void 0}e(i,"parsePositiveMsNoDefault");function ce(){return i(process.env.CRTR_TEST_UNATTENDED_PARK_MS)}e(ce,"envTestUnattendedParkMs");function _e(){return i(process.env.CRTR_UNATTENDED_PARK_MS)}e(_e,"envUnattendedParkMs");function O(){return i(process.env.CRTR_TEST_PARK_SUMMARY_GRACE_MS)}e(O,"envTestParkSummaryGraceMs");const m=5*6e4;function Re(){return O()??m}e(Re,"parkSummaryGraceMs");export{v as INBOX_BETWEEN_TURNS_ENV,q as envBashAutoBgDeadlineMs,$ as envBashValveMs,Q as envBrokerEngine,re as envCrtrdTcp,te as envCrtrdToken,se as envDebug,G as envExecutionId,F as envGrantee,V as envHomeOverride,W as envInboxBetweenTurns,ue as envMaxLiveBrokers,g as envModelExact,k as envModelIntent,ne as envNoDaemonAutostart,oe as envNoExports,b as envNodeCwd,L as envNodeId,ee as envPiBinaryOverride,J as envPidfileOverride,K as envProfileId,z as envRecapIdleMs,Z as envRecapModelOverride,w as envRuntimeHomeOverride,h as envScopes,y as envStreamWatchdogMs,O as envTestParkSummaryGraceMs,ce as envTestUnattendedParkMs,_e as envUnattendedParkMs,ie as envWarnLiveBrokers,j as envWatcherBackstopMs,Y as envWatcherDebounceMs,X as envWatcherTickMs,P as isSandboxedNode,f as launchSnapshotFile,Re as parkSummaryGraceMs,d as sandboxCliNodeId,H as scopeAllowed};
@@ -26,20 +26,38 @@ if (run.kind === 'result') console.log(run.output_parsed.root_cause);
26
26
 
27
27
  ## Go live with an app key
28
28
 
29
- Once you have installed the SDK and tried the local flow in [Getting started](./getting-started.md), generate the app's key on your server:
29
+ Once you have installed the SDK and tried the local flow in [Getting started](./getting-started.md), register the app with `crtr cloud apps register` (or the console), then generate its key and pull its settings on your server:
30
30
 
31
31
  ```bash
32
32
  npx @north-light/sdk keygen --app <client id>
33
+ npx @north-light/sdk env --app <client id>
33
34
  ```
34
35
 
35
- `keygen` saves the private JWK to `local/app-key.json` (mode `0600`); keep that file out of git. With `--app`, it offers to add the public key to your registered app through your own `crtr`, as an additional key that never replaces the current one. Otherwise, click **Add key** on the app's **Sign-in** tab and paste the public JWK printed on stdout (or paste it under **Register an app** for a new app). An app authenticates only with its key; the directory issues no client secret. On your server, load the saved key and pass it as `privateKey`:
36
+ `keygen` saves the private JWK to `local/app-key.json` (mode `0600`); keep that file out of git. With `--app`, it offers to add the public key to your registered app through your own `crtr`, as an additional key that never replaces the current one. Otherwise, click **Add key** on the app's **Sign-in** tab and paste the public JWK printed on stdout (or paste it under **Register an app** for a new app). An app authenticates only with its key; the directory issues no client secret.
37
+
38
+ `env` reads the registration through `crtr cloud apps get` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env` (`--out` picks another file), keeping every other line. Both commands need this machine's `crtr` joined to your crouter cloud account; on a machine that isn't, they print the join step and exit 2. An app is configured from four variables:
39
+
40
+ | Variable | Value |
41
+ |---|---|
42
+ | `CROUTER_ISSUER` | The directory's issuer URL |
43
+ | `CROUTER_CLIENT_ID` | The app's client id |
44
+ | `APP_ORIGIN` | The app's origin; its redirect URI is `${APP_ORIGIN}/auth/callback` by convention |
45
+ | `CROUTER_KEY_FILE` | Path to the private JWK, default `local/app-key.json`. `env` never writes it |
46
+
47
+ The SDK reads no environment itself: load the values in your own config and pass them to `OAuth2Client`.
36
48
 
37
49
  ```ts
38
50
  import {readFile} from 'node:fs/promises';
39
51
  import {OAuth2Client} from '@north-light/sdk';
40
52
 
41
- const privateKey = JSON.parse(await readFile('local/app-key.json', 'utf8'));
42
- const oauth = new OAuth2Client({clientId, issuer, redirectUri, privateKey, store}); // store: your ConnectionStore
53
+ const privateKey = JSON.parse(await readFile(process.env.CROUTER_KEY_FILE ?? 'local/app-key.json', 'utf8'));
54
+ const oauth = new OAuth2Client({
55
+ clientId: process.env.CROUTER_CLIENT_ID!,
56
+ issuer: process.env.CROUTER_ISSUER!,
57
+ redirectUri: `${process.env.APP_ORIGIN}/auth/callback`,
58
+ privateKey,
59
+ store, // your ConnectionStore
60
+ });
43
61
  const {url, state, codeVerifier, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:llm', 'crtr:act'], state: sessionState});
44
62
  // Save state, codeVerifier, nonce in the user's session, then redirect to url.
45
63
  const connection = await oauth.exchangeCode({code: callbackCode, state: callbackState, expectedState: state, codeVerifier, nonce});
@@ -199,6 +199,20 @@ await watcher.stop();
199
199
  - **`stop()`** stops sweeping, aborts every open read, and resolves once every turn has ended; no handler starts afterwards.
200
200
  - **One person, one request.** Without the watcher, `runtime.grant.get({wait: 25, since: grant.version})` holds a single read the same way; the request timeout is extended by `wait`. `wait` outside integer 1–25 throws `RangeError` before sending.
201
201
 
202
+ ### Show what a person's month has cost
203
+
204
+ A person's grant caps what your app may spend of their AI subscription each UTC month (`grant.spend_cap`; crouter cloud sets $50). `usage.get()` answers where the month stands:
205
+
206
+ ```ts
207
+ const usage = await runtime.usage.get(); // or {month: '2026-09'}
208
+ // {grantee, month, usd_equivalent, by_model, app_paid, spend_cap, reset_at}
209
+ ```
210
+
211
+ - **`usd_equivalent`** is user-paid spend at list price: model completions and provider receipts. Receipts have no `by_model` row, so it can exceed the sum of `by_model`. A model with no list price reports `usd_equivalent: null` on its row and adds nothing.
212
+ - **`app_paid`** is usage your app pays for. It never counts toward `spend_cap`.
213
+ - **At the cap.** Once `usd_equivalent` reaches `spend_cap`, user-paid model and provider calls answer 402 `cap_exhausted` (a `RateLimitError` with `retryable: false`, `userAction: 'raise_cap'` and `resetAt`) until `reset_at`, the first instant of the next UTC month. A run whose model call is refused ends with outcome reason `cap_exhausted`. The check runs before each call, so the call that crosses the cap completes and the next one is refused. Do not retry; tell the person when the count resets.
214
+ - `spend_cap: null` means the grant is not capped.
215
+
202
216
  ### Link to the app's settings on crouter cloud
203
217
 
204
218
  Each person who uses your app has a page on their crouter cloud account where they change or remove what the app may do. Link to it with the URL the directory advertises; never compose the path yourself:
@@ -209,7 +223,7 @@ const href = await oauth.accountAppUrl(); // e.g. https://<directory>/account/ap
209
223
 
210
224
  - **What it is.** The discovery document's `account_app_uri_template` with its one `{client_id}` replaced by your encoded client id. It is the same URL for every person: the directory shows whoever is signed in to it their own page for your app (and sends someone without a directory session through sign-in first). Someone without an active grant, a sign-in-only visitor for one, sees that the app is not connected to their account.
211
225
  - **What it needs.** No user and no connection, so it serves owners and visitors alike. Its only request is the discovery read that `authorizeUrl`, `verifyIdToken` and `revoke` share.
212
- - **Errors.** Each is a `CrouterError` (code `crouter_error`) whose message names the fault: the directory advertises no `account_app_uri_template`, the template does not contain `{client_id}` exactly once, or the expansion is not a URL on the issuer's origin. A discovery read that fails throws `Directory discovery failed: <status>` or the fetch error; one that takes longer than 10 seconds throws the timeout. A failed or timed-out read is not kept, so the next call reads again; a successful one is kept for the client's life, so a template the directory changes reaches your app at its next start.
226
+ - **Errors.** Each is a `CrouterError` (code `crouter_error`) whose message names the fault: the directory advertises no `account_app_uri_template`, the template does not contain `{client_id}` exactly once, the expansion is not a URL on the issuer's origin, or an endpoint field of the discovery document (`authorization_endpoint`, `token_endpoint`, `jwks_uri`, `revocation_endpoint`) is not an absolute https URL (`http:` when the issuer is `http:`) or `token_endpoint_auth_methods_supported` is not an array of strings. A discovery read that fails throws `Directory discovery failed: <status>` or the fetch error; one that takes longer than `timeoutMs` (default 10 seconds) throws `DeadlineExceededError`. A failed or timed-out read is not kept, so the next call reads again; a successful one is kept for the client's life, so a template the directory changes reaches your app at its next start.
213
227
  - **Warm it at startup and bound it on page loads.** Call `void oauth.accountAppUrl().catch(log)` when the process starts, so the discovery read is usually done before the first page needs it. A page that must not wait on the directory races it against a short timer (a couple of seconds, under the SDK's 10 s) and hides the link when the race is lost; the read keeps running and a later page load reuses its result.
214
228
 
215
229
  ### Testing your app
@@ -276,10 +290,27 @@ await client.nodes.retrieve(id, {
276
290
  | `signal` | `AbortSignal` | Aborts the underlying `fetch`. Raises `APIUserAbortError`. |
277
291
  | `timeout` | `number` (ms) | Overrides the client `timeout`. |
278
292
  | `maxRetries` | `number` | Overrides the client `maxRetries`. |
293
+ | `budgetMs` | `number` (ms) | One overall deadline for the call, from now. It covers every attempt, every retry wait (a server's `Retry-After` too) and the response body; `timeout` still bounds each attempt but never past it. See [One deadline per call](#one-deadline-per-call). |
294
+ | `deadlineAt` | `number` (epoch ms) | The same deadline as an absolute instant, for passing one deadline to several calls. With `budgetMs` too, the earlier wins. |
279
295
  | `headers` | `Record<string, string>` | Merged over `defaultHeaders`, client `headers`, and the token-generated header; a matching name, including `Authorization`, wins. |
280
296
 
281
297
  `signal` is real cancellation. The long-polling helpers (`waitForOutcome`, `createAndWait`, `parse`) honour it between polls and during the in-flight request, and aborting them stops the client — it does not cancel the node. To stop the run itself, call `client.nodes.cancel(id)` or `client.nodes.interrupt(id)`. Streams use `NodeEventsOptions`: the same fields except `timeout`, plus `after` for `nodes.events`; see [Streaming](./streaming.md).
282
298
 
299
+ ### One deadline per call
300
+
301
+ Without `budgetMs`/`deadlineAt` a call has one `timeout` per attempt, so a keyed send (`runs.start`, `runs.message`) that is retried twice can take three timeouts plus the waits between them, and a server's `retry_after_s: 120` is waited out in full. Give the call a deadline and its total time is bounded:
302
+
303
+ ```ts
304
+ await client.runs.message(runId, text, { budgetMs: 20_000 });
305
+ ```
306
+
307
+ - The deadline is fixed once, when the call starts, and every attempt, every backoff, every `retry_after_s`/`Retry-After` wait, the token fetch and the refresh after a 401 share it. The call rejects at the deadline (plus the scheduling of one timer) whether the time goes to the headers, the body, a retry wait or the token hook, even when your `fetch` ignores its `AbortSignal`. The compound helpers (`runs.wait`, `runs.parse`, `nodes.waitForOutcome`, `nodes.createAndWait`, `nodes.parse`) fix one deadline for the whole helper, not one per poll.
308
+ - A retry wait that would reach the deadline is not slept: the call rejects at once with `DeadlineExceededError` (`code: 'deadline_exceeded'`, status 504). `reason` is `'retry_after'` when the server asked for a wait longer than the time left (`retryAfterRequestS` is that request), and `'elapsed'` when time simply ran out; `deadlineAt` is the instant, and `cause` is the last refusal.
309
+ - A stream (`nodes.stream`, `nodes.events`): `budgetMs` / `deadlineAt` bound opening it (the create or retrieve, the token hook and the connection); once open it lives until it ends or its `signal` aborts.
310
+ - `budgetMs` and `deadlineAt` must fit a timer: at most 2,147,483,647 ms (about 24.8 days) from now, or the call throws `RangeError`.
311
+ - **Holding a lease while you call.** An app that takes a lease or lock for an SDK call sets `budgetMs` and keeps the lease longer than it, with margin, so the call has ended or been abandoned before the lease can be taken over: `LEASE_MS >= BUDGET_MS + MARGIN_MS`. State that inequality next to the constants (or assert it at module load) rather than renewing the lease while a call is unbounded.
312
+ - `OAuth2Client` gives each directory operation (discovery, a code exchange or refresh, a revocation, the JWKS read behind `verifyIdToken`) the same kind of deadline, `OAuth2Options.timeoutMs` (default 10 s). A refresh runs under the user's store lock, so the lock is held no longer than `timeoutMs` plus the store's own time.
313
+
283
314
  ## What is not on the client
284
315
 
285
316
  | Convention | Why it is absent |
@@ -32,6 +32,7 @@ Every class below extends `APIError`, which carries `status`, `code`, `message`,
32
32
  | HTTP 409 | `ConflictError` |
33
33
  | HTTP 413, 422 | `UnprocessableEntityError` |
34
34
  | HTTP 429 | `RateLimitError` |
35
+ | Code `deadline_exceeded` | `DeadlineExceededError` (extends `APIConnectionTimeoutError`; status 504; `reason` `elapsed` or `retry_after`, `deadlineAt`, `cause`) |
35
36
  | HTTP 504 or code `request_timeout` | `APIConnectionTimeoutError` (extends `APIConnectionError`) |
36
37
  | Other HTTP ≥ 500 | `InternalServerError` |
37
38
  | Code `daemon_unavailable`, `transport_error`, `daemon_request_interrupted`, or `daemon_health_unavailable`; or a non-API transport failure | `APIConnectionError` |
@@ -104,6 +105,8 @@ try {
104
105
  const outcome = await client.nodes.waitForOutcome(runId);
105
106
  ```
106
107
 
108
+ A call with `budgetMs` (or `deadlineAt`) has one deadline over all of these attempts and waits, a server's `Retry-After` included: a wait longer than the time left rejects at once with `DeadlineExceededError` instead of sleeping (see [One deadline per call](./client.md#one-deadline-per-call)). Without one, a keyed send honours any `retry_after_s`.
109
+
107
110
  Override the policy per request with `{ maxRetries: 0 }` or `{ maxRetries: 5 }`. Raising it on a `POST` other than `runs.start` and `runs.message` still does not make the client retry that `POST`.
108
111
 
109
112
  ## In-repo callers
@@ -172,20 +172,42 @@ An outcome is returned, never thrown — including a decline and a failure. Only
172
172
 
173
173
  ## Go live with an app key
174
174
 
175
- Register your app in the directory console, then generate its signing key on your app server:
175
+ Register your app with `crtr cloud apps register` (or in the directory console), then generate its signing key and pull its settings on your app server:
176
176
 
177
177
  ```bash
178
178
  npx @north-light/sdk keygen --app <client id>
179
+ npx @north-light/sdk env --app <client id>
179
180
  ```
180
181
 
181
- This creates `local/app-key.json` with mode `0600` (keep it out of git) and prints the public JWK on stdout. With `--app`, `keygen` offers to add that public key to your app through your own `crtr` (`crtr cloud apps keys add`). It asks first and `--yes` skips the question; with no terminal to ask on (CI, piped input) and no `--yes`, it adds nothing. The upload only ever adds a key: it never replaces or retires the key your app already has. If `crtr` isn't installed or the add fails, `keygen` says why and prints the manual steps instead: click **Add key** on your app's **Sign-in** tab and paste the public JWK, or paste it under **Register an app** for a new app. An app authenticates only with its key: the directory issues no client secret. Use the saved private JWK for the OAuth connect flow:
182
+ `keygen` creates `local/app-key.json` with mode `0600` (keep it out of git) and prints the public JWK on stdout. With `--app`, `keygen` offers to add that public key to your app through your own `crtr` (`crtr cloud apps keys add`). It asks first and `--yes` skips the question; with no terminal to ask on (CI, piped input) and no `--yes`, it adds nothing. The upload only ever adds a key: it never replaces or retires the key your app already has. If `crtr` isn't installed or the add fails, `keygen` says why and prints the manual steps instead: click **Add key** on your app's **Sign-in** tab and paste the public JWK, or paste it under **Register an app** for a new app. An app authenticates only with its key: the directory issues no client secret.
183
+
184
+ `env` runs `crtr cloud apps get --app-id <client id>` and writes `CROUTER_ISSUER`, `CROUTER_CLIENT_ID`, and `APP_ORIGIN` into `local/app.env` (`--out <path>` writes another file). It updates those three lines in place, keeps every other line and comment, and creates the file with mode `0600` when it doesn't exist. It never writes `CROUTER_KEY_FILE` or anything secret. It prints the values it set and the key file your app will read.
185
+
186
+ Both commands reach your app through this machine's `crtr`, so the machine must be joined to your crouter cloud account. On one that isn't, they say so, print the join step (create a join token on `/account/machines`, then `crtr sys join`), and exit 2.
187
+
188
+ An app built on the SDK is configured from four variables:
189
+
190
+ | Variable | Value | Where it comes from |
191
+ |---|---|---|
192
+ | `CROUTER_ISSUER` | The directory's issuer URL | `env` |
193
+ | `CROUTER_CLIENT_ID` | The app's client id | `env` |
194
+ | `APP_ORIGIN` | The app's origin. Its redirect URI is `${APP_ORIGIN}/auth/callback` by convention | `env` (the registration's origin) |
195
+ | `CROUTER_KEY_FILE` | Path to the private JWK, default `local/app-key.json` | You; it differs per deployment (a container might read `/run/secrets/app_key`) |
196
+
197
+ The SDK reads no environment and has no `fromEnv` helper: your app loads the values and passes them to `OAuth2Client`:
182
198
 
183
199
  ```ts
184
200
  import {readFile} from 'node:fs/promises';
185
201
  import {OAuth2Client} from '@north-light/sdk';
186
202
 
187
- const privateKey = JSON.parse(await readFile('local/app-key.json', 'utf8'));
188
- const oauth = new OAuth2Client({clientId, issuer, redirectUri, privateKey, store}); // store: your ConnectionStore
203
+ const config = {
204
+ issuer: process.env.CROUTER_ISSUER!,
205
+ clientId: process.env.CROUTER_CLIENT_ID!,
206
+ origin: process.env.APP_ORIGIN!,
207
+ keyFile: process.env.CROUTER_KEY_FILE ?? 'local/app-key.json',
208
+ };
209
+ const privateKey = JSON.parse(await readFile(config.keyFile, 'utf8'));
210
+ const oauth = new OAuth2Client({clientId: config.clientId, issuer: config.issuer, redirectUri: `${config.origin}/auth/callback`, privateKey, store}); // store: your ConnectionStore
189
211
  const {url, codeVerifier, state, nonce} = await oauth.authorizeUrl({scopes: ['openid', 'crtr:llm', 'crtr:act'], state: sessionState});
190
212
  // Save codeVerifier, state, nonce in the user's session; redirect them to url.
191
213
  const connection = await oauth.exchangeCode({code: callbackCode, codeVerifier, state: callbackState, expectedState: state, nonce});
@@ -7,6 +7,29 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
7
7
 
8
8
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
9
9
 
10
+ ## 0.3.415
11
+
12
+ ### Monthly spend cap enforced; `usage.get` (additive; one behavior change)
13
+
14
+ - **Behavior change:** user-paid model calls (`/v1/llm`) and provider calls now count against the grant's `spend_cap` (from `grant.get()`; crouter cloud 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.
15
+ - 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](./client.md#show-what-a-persons-month-has-cost).
16
+ - 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.
17
+
18
+ ### `npx @north-light/sdk env` (additive); keygen on an unjoined machine exits 2
19
+
20
+ - 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](./getting-started.md#go-live-with-an-app-key).
21
+ - `keygen --app` on a machine whose `crtr` isn't joined to a crouter cloud account now prints the join step and the manual steps and exits 2. A missing `crtr` keeps its message and exit 0.
22
+
23
+ ### One deadline per SDK call, and a deadline on every OAuth/JWKS fetch (additive, with one behaviour change)
24
+
25
+ Not breaking: no type or wire shape an app reads is removed or changed. Apps pinned to the previous release keep working unchanged.
26
+
27
+ - `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](./client.md#one-deadline-per-call).
28
+ - 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.
29
+ - `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.
30
+ - **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.
31
+ - `OAuth2Client` validates the discovery document: `authorization_endpoint`, `token_endpoint`, `jwks_uri` (and `revocation_endpoint`, when present) must be absolute https URLs (`http:` when the issuer is `http:`), and `token_endpoint_auth_methods_supported` an array of strings. A wrong type is a `CrouterError` naming the field instead of a later `TypeError`.
32
+
10
33
  ## 0.3.409
11
34
 
12
35
  ### `@north-light/sdk/testing`: isolated runs start, `FakeCall.runId`, one `runs` shape (one breaking change)
@@ -32,7 +32,11 @@ const client = await oauth.client('user:o1'); // first call refres
32
32
 
33
33
  `cloud.crouter(userId)` returns a real `Crouter` on that person's runtime with a fresh token and no refresh. Refusals and refreshes never reach it. Use it only for code whose production signature takes a `Crouter`. Code that goes through `OAuth2Client` in production should go through `oauth.client` in tests too.
34
34
 
35
- Options: `new FakeCloud({clientId, issuer = 'https://directory.test', runtimeUrl = 'https://runtime.test' (or (userId) => url), runtimeId = 'runtime:test', runtimeClientId = 'runtime-client', runtimeVersion?, streamIdleMs = 60000, accountAppUriTemplate = '<issuer>/account/apps/{client_id}' (null omits it), runNotice?, isolatedRuns = 'available'})`.
35
+ Options: `new FakeCloud({clientId, issuer = 'https://directory.test', runtimeUrl = 'https://runtime.test' (or (userId) => url), runtimeId = 'runtime:test', runtimeClientId = 'runtime-client', runtimeVersion?, streamIdleMs = 60000, accountAppUriTemplate = '<issuer>/account/apps/{client_id}' (null omits it), runNotice?, isolatedRuns = 'available', clock = Date.now})`.
36
+
37
+ `clock` is `{now(): number}`; every timestamp the fake writes reads it (run `created_at` and `settled_at`, grant `exp`, memory `created_at`/`updated_at`, access-token `iat`/`exp`, `calls[].at`, `directoryCalls[].at`) and the idempotency window runs on it. Pass `manualClock()` (from `@north-light/sdk/testing`; `now()`, `advance(ms)`) to step time by hand. Stream idle, long-poll waits and run-notice retries stay on real timers.
38
+
39
+ `cloud.activate(userId, {scopes?, dependencies?})` makes the grant active with no stored connection, for an app that mints a per-user runtime token itself. The fake directory serves that mint at `POST <issuer>/token` with `grant_type=client_credentials&sub=<userId>` (the client assertion is not verified): 200 `{access_token, token_type: 'Bearer', expires_in: 600, runtimeUrl}` (the token's `exp` claim is 600 s after `iat`; the fake runtime does not enforce token expiry); a missing `sub` is 400 `unauthorized_client`; an unknown or revoked user is 400 `invalid_grant` (`no active grant`); `user.refuse('runtime_provisioning')` is 400 `invalid_grant` with `error_description: runtime_provisioning`. A mint is a `directory.token` call (`userId` is the `sub`), so `failNext('directory.token', …, {user})` applies to it.
36
40
 
37
41
  ### Isolated runs
38
42
 
@@ -71,7 +75,9 @@ What a turn's input is: `reply` and `failTurn` deliver `messageIds` (default: ev
71
75
 
72
76
  Messages follow the runtime: `start_turn` true makes the run `running` at once and calls the responder; `start_turn` false only records it. A message to a run settled `canceled` or `deadline_exceeded` is refused 409 `run_settled`; to a run settled any other way it reopens the run. A deleted run answers 404.
73
77
 
74
- Idempotency follows the runtime too: the same `Idempotency-Key` with the same body replays the first answer (no new run, no responder call); the same key with a different body, or reused between `run.start` and `run.message`, is 409 `idempotency_conflict`. After a run is deleted, its start key creates a new run.
78
+ Idempotency follows the runtime too: the same `Idempotency-Key` with the same body replays the first answer (no new run, no responder call); the same key with a different body, or reused between `run.start` and `run.message`, is 409 `idempotency_conflict`. After a run is deleted, its start key creates a new run. A key is live for 24 hours (`IDEMPOTENCY_WINDOW_MS`): at exactly the window it reads as absent, and the next accept replaces the record. Step the window with `clock`.
79
+
80
+ `run.openStreams` counts the run's open event streams. It rises when the SDK starts reading the response body, which is after `cloud.calls` records `run.events`, so poll `openStreams`, not `calls`, to know a stream is attached. It falls on idle, error, abort or revoke. Run notices are owed only while no stream is open.
75
81
 
76
82
  ## Grants and setup state
77
83
 
@@ -158,6 +164,6 @@ Build two app instances (two `OAuth2Client`s) on the same `cloud`. They see the
158
164
 
159
165
  ## Fidelity
160
166
 
161
- FakeCloud is checked against the real runtime on every crouter release: one set of conformance scenarios runs on the fake and on the real app listener (run start and idempotency, messages, reply streams, long-poll, every settle outcome, delete, the event stream's edges, grant holds, memory, providers, run notices). Two behaviours are fake-only because the runtime cannot be scripted that way: a successful provider call (the real side needs an installed provider), and `setActivity`. An isolated run that starts is checked only against `startRun`'s documented checks: the real listener in the suite runs without the Linux root launcher, so on it the `isolatedRuns: 'unavailable'` refusal is what is conformance-checked.
167
+ FakeCloud is checked against the real runtime on every crouter release: one set of conformance scenarios runs on the fake and on the real app listener (run start and idempotency, messages, reply streams, long-poll, every settle outcome, delete, the event stream's edges, grant holds, memory, providers, run notices). The keyed-write scenarios K1–K8 (equal-key replay, conflict, concurrent equal keys accepted once, the 24h window, replay after cancel, a key bound to its operation, keys scoped to the grantee, concurrent notes) are exported as `@north-light/sdk/testing/conformance` (`keyedWriteScenarios`, `IDEMPOTENCY_WINDOW_MS`) and run on FakeCloud and on the real daemon; an app test double for the runtime should run them against its own target. Two behaviours are fake-only because the runtime cannot be scripted that way: a successful provider call (the real side needs an installed provider), and `setActivity`. An isolated run that starts is checked only against `startRun`'s documented checks: the real listener in the suite runs without the Linux root launcher, so on it the `isolatedRuns: 'unavailable'` refusal is what is conformance-checked.
162
168
 
163
169
  Not faked: sign-in (`authorizeUrl`, `exchangeCode`, ID tokens — start from `cloud.connect`), token revocation, the owner and local-daemon surface (`nodes`, `canvas`, `crons`, `files`, …), and several apps on one cloud. `trace.get`, `run.rename`, `run.interrupt` and `profile.list` answer the right shape only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.414",
3
+ "version": "0.3.416",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -134,7 +134,7 @@
134
134
  },
135
135
  "license": "UNLICENSED",
136
136
  "dependencies": {
137
- "@north-light/identity": "0.3.414",
137
+ "@north-light/identity": "0.3.416",
138
138
  "@earendil-works/pi-agent-core": "0.99.1",
139
139
  "@earendil-works/pi-ai": "0.99.1",
140
140
  "@earendil-works/pi-coding-agent": "0.99.1",
@@ -27,7 +27,31 @@ export type Scope = 'crtr:llm' | 'crtr:act' | 'crtr:schedule' | `crtr:memory:${'
27
27
  export declare const ENFORCED: ReadonlySet<string>;
28
28
  export declare function parseScope(value: string, grantee?: string): Scope;
29
29
  export declare function covers(held: string, requested: string): boolean;
30
- /** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. */
30
+ /** A directory fetch ran past its deadline (or was aborted by its caller before that). `code` is `deadline_exceeded`;
31
+ * `deadlineAt` is the instant (epoch ms) the operation had to finish by. The `@north-light/sdk` maps it to its
32
+ * `DeadlineExceededError`. */
33
+ export declare class FetchDeadlineError extends Error {
34
+ readonly deadlineAt: number;
35
+ readonly url: string;
36
+ readonly code = "deadline_exceeded";
37
+ constructor(deadlineAt: number, url: string);
38
+ }
39
+ /** The default deadline for one `JwksCache.load`, covering its discovery read and its key-set read together. */
40
+ export declare const DEFAULT_JWKS_TIMEOUT_MS = 10000;
41
+ /**
42
+ * One `fetch` plus the reading of its answer (`read`, e.g. `response.json()`), bounded by one absolute deadline. The
43
+ * request is aborted when the deadline passes or `signal` fires, and the returned promise rejects at that moment
44
+ * even when `fetcher` ignores its signal, so a directory that accepts the connection and never answers cannot hold a
45
+ * caller (or a lock the caller holds) past `deadlineAt`. Rejects `FetchDeadlineError` at the deadline and the signal's
46
+ * reason when `signal` fired first.
47
+ */
48
+ export declare function boundedFetch<T>(fetcher: typeof fetch, url: string, init: RequestInit, bounds: {
49
+ deadlineAt: number;
50
+ signal?: AbortSignal;
51
+ }, read: (response: Response) => Promise<T>): Promise<T>;
52
+ /** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. Every `load` is bounded by
53
+ * one deadline (`timeoutMs`, default `DEFAULT_JWKS_TIMEOUT_MS`) over its discovery read and its key-set read together;
54
+ * callers that join an in-flight load share that load's deadline. */
31
55
  export declare class JwksCache {
32
56
  readonly issuer: string;
33
57
  private readonly fetcher;
@@ -35,7 +59,10 @@ export declare class JwksCache {
35
59
  private lastUnknownRefresh;
36
60
  private loading;
37
61
  private discovery;
38
- constructor(issuer: string, fetcher?: typeof fetch, jwksUri?: string);
62
+ private readonly timeoutMs;
63
+ constructor(issuer: string, fetcher?: typeof fetch, jwksUri?: string, options?: {
64
+ timeoutMs?: number;
65
+ });
39
66
  load(): Promise<JSONWebKeySet>;
40
67
  private fetchKeys;
41
68
  forKid(kid: string | undefined): Promise<JSONWebKeySet>;
@@ -71,7 +71,70 @@ export function covers(held, requested) {
71
71
  }
72
72
  return held === requested;
73
73
  }
74
- /** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. */
74
+ /** A directory fetch ran past its deadline (or was aborted by its caller before that). `code` is `deadline_exceeded`;
75
+ * `deadlineAt` is the instant (epoch ms) the operation had to finish by. The `@north-light/sdk` maps it to its
76
+ * `DeadlineExceededError`. */
77
+ export class FetchDeadlineError extends Error {
78
+ deadlineAt;
79
+ url;
80
+ code = 'deadline_exceeded';
81
+ constructor(deadlineAt, url) {
82
+ super(`${url} did not answer by the deadline (${new Date(deadlineAt).toISOString()})`);
83
+ this.deadlineAt = deadlineAt;
84
+ this.url = url;
85
+ this.name = 'FetchDeadlineError';
86
+ }
87
+ }
88
+ /** The default deadline for one `JwksCache.load`, covering its discovery read and its key-set read together. */
89
+ export const DEFAULT_JWKS_TIMEOUT_MS = 10_000;
90
+ /**
91
+ * One `fetch` plus the reading of its answer (`read`, e.g. `response.json()`), bounded by one absolute deadline. The
92
+ * request is aborted when the deadline passes or `signal` fires, and the returned promise rejects at that moment
93
+ * even when `fetcher` ignores its signal, so a directory that accepts the connection and never answers cannot hold a
94
+ * caller (or a lock the caller holds) past `deadlineAt`. Rejects `FetchDeadlineError` at the deadline and the signal's
95
+ * reason when `signal` fired first.
96
+ */
97
+ export async function boundedFetch(fetcher, url, init, bounds, read) {
98
+ const { deadlineAt, signal } = bounds;
99
+ if (signal?.aborted)
100
+ throw signal.reason;
101
+ const remaining = deadlineAt - Date.now();
102
+ if (remaining <= 0)
103
+ throw new FetchDeadlineError(deadlineAt, url);
104
+ const controller = new AbortController();
105
+ let timer;
106
+ let onAbort;
107
+ const stop = new Promise((_resolve, reject) => {
108
+ timer = setTimeout(() => { const error = new FetchDeadlineError(deadlineAt, url); controller.abort(error); reject(error); }, remaining);
109
+ if (signal) {
110
+ onAbort = () => { controller.abort(signal.reason); reject(signal.reason); };
111
+ signal.addEventListener('abort', onAbort, { once: true });
112
+ }
113
+ });
114
+ const work = (async () => {
115
+ const response = await fetcher(url, { ...init, signal: controller.signal });
116
+ // A response that arrives after the deadline won is discarded: close it so its connection is not retained.
117
+ if (controller.signal.aborted) {
118
+ void response.body?.cancel().catch(() => { });
119
+ throw controller.signal.reason;
120
+ }
121
+ return read(response);
122
+ })();
123
+ // The loser of the race must not surface as an unhandled rejection.
124
+ work.catch(() => { });
125
+ stop.catch(() => { });
126
+ try {
127
+ return await Promise.race([work, stop]);
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ if (onAbort)
132
+ signal?.removeEventListener('abort', onAbort);
133
+ }
134
+ }
135
+ /** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. Every `load` is bounded by
136
+ * one deadline (`timeoutMs`, default `DEFAULT_JWKS_TIMEOUT_MS`) over its discovery read and its key-set read together;
137
+ * callers that join an in-flight load share that load's deadline. */
75
138
  export class JwksCache {
76
139
  issuer;
77
140
  fetcher;
@@ -79,16 +142,20 @@ export class JwksCache {
79
142
  lastUnknownRefresh = -Infinity;
80
143
  loading;
81
144
  discovery;
82
- constructor(issuer, fetcher = fetch, jwksUri) {
145
+ timeoutMs;
146
+ constructor(issuer, fetcher = fetch, jwksUri, options = {}) {
83
147
  this.issuer = issuer;
84
148
  this.fetcher = fetcher;
85
149
  if (jwksUri)
86
150
  this.discovery = { jwks_uri: jwksUri };
151
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS;
152
+ if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647)
153
+ throw new RangeError('JwksCache timeoutMs must be a positive finite number of at most 2147483647');
87
154
  }
88
155
  async load() {
89
156
  if (this.loading)
90
157
  return this.loading;
91
- const loading = this.fetchKeys();
158
+ const loading = this.fetchKeys(Date.now() + this.timeoutMs);
92
159
  this.loading = loading;
93
160
  try {
94
161
  return await loading;
@@ -97,20 +164,22 @@ export class JwksCache {
97
164
  this.loading = undefined;
98
165
  }
99
166
  }
100
- async fetchKeys() {
167
+ async fetchKeys(deadlineAt) {
101
168
  if (!this.discovery) {
102
- const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`);
103
- if (!response.ok)
104
- throw new Error(`Directory discovery failed: ${response.status}`);
105
- const value = await response.json();
169
+ const value = await boundedFetch(this.fetcher, `${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {}, { deadlineAt }, async (response) => {
170
+ if (!response.ok)
171
+ throw new Error(`Directory discovery failed: ${response.status}`);
172
+ return await response.json();
173
+ });
106
174
  if (!value || typeof value !== 'object' || !('jwks_uri' in value) || typeof value.jwks_uri !== 'string')
107
175
  throw new Error('Directory discovery has no jwks_uri');
108
176
  this.discovery = { jwks_uri: value.jwks_uri };
109
177
  }
110
- const response = await this.fetcher(this.discovery.jwks_uri);
111
- if (!response.ok)
112
- throw new Error(`Directory JWKS failed: ${response.status}`);
113
- const keys = await response.json();
178
+ const keys = await boundedFetch(this.fetcher, this.discovery.jwks_uri, {}, { deadlineAt }, async (response) => {
179
+ if (!response.ok)
180
+ throw new Error(`Directory JWKS failed: ${response.status}`);
181
+ return await response.json();
182
+ });
114
183
  if (!keys || typeof keys !== 'object' || !('keys' in keys) || !Array.isArray(keys.keys))
115
184
  throw new Error('Invalid directory JWKS');
116
185
  this.keys = keys;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.414",
3
+ "version": "0.3.416",
4
4
  "description": "crouter cloud access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.414",
3
+ "version": "0.3.416",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.414",
9
+ "version": "0.3.416",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "0.99.1",
19
19
  "@earendil-works/pi-coding-agent": "0.99.1",
20
20
  "@earendil-works/pi-tui": "0.99.1",
21
- "@north-light/identity": "0.3.414",
21
+ "@north-light/identity": "0.3.416",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -13814,17 +13814,17 @@
13814
13814
  },
13815
13815
  "packages/crouter-api": {
13816
13816
  "name": "@north-light/api",
13817
- "version": "0.3.414",
13817
+ "version": "0.3.416",
13818
13818
  "license": "UNLICENSED"
13819
13819
  },
13820
13820
  "packages/crouter-env-docker": {
13821
13821
  "name": "@north-light/env-docker",
13822
- "version": "0.3.414",
13822
+ "version": "0.3.416",
13823
13823
  "license": "UNLICENSED"
13824
13824
  },
13825
13825
  "packages/crouter-identity": {
13826
13826
  "name": "@north-light/identity",
13827
- "version": "0.3.414",
13827
+ "version": "0.3.416",
13828
13828
  "license": "UNLICENSED",
13829
13829
  "dependencies": {
13830
13830
  "jose": "^6.2.1"
@@ -13832,20 +13832,20 @@
13832
13832
  },
13833
13833
  "packages/crouter-plugin": {
13834
13834
  "name": "@north-light/plugin",
13835
- "version": "0.3.414",
13835
+ "version": "0.3.416",
13836
13836
  "license": "UNLICENSED",
13837
13837
  "dependencies": {
13838
- "@north-light/api": "0.3.414",
13839
- "@north-light/identity": "0.3.414"
13838
+ "@north-light/api": "0.3.416",
13839
+ "@north-light/identity": "0.3.416"
13840
13840
  }
13841
13841
  },
13842
13842
  "packages/crouter-sdk": {
13843
13843
  "name": "@north-light/sdk",
13844
- "version": "0.3.414",
13844
+ "version": "0.3.416",
13845
13845
  "license": "UNLICENSED",
13846
13846
  "dependencies": {
13847
- "@north-light/api": "0.3.414",
13848
- "@north-light/identity": "0.3.414",
13847
+ "@north-light/api": "0.3.416",
13848
+ "@north-light/identity": "0.3.416",
13849
13849
  "jose": "^6.2.1"
13850
13850
  },
13851
13851
  "bin": {