@north-light/crouter 0.3.281 → 0.3.283

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 (188) hide show
  1. package/dist/api/client.d.ts +19 -3
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/broker-ops.d.ts +11 -0
  4. package/dist/api/dto/broker.d.ts +3 -8
  5. package/dist/api/dto/common.d.ts +1 -1
  6. package/dist/api/dto/messages.d.ts +2 -2
  7. package/dist/api/dto/node-outcomes.d.ts +80 -0
  8. package/dist/api/dto/node-outcomes.js +2 -0
  9. package/dist/api/dto/nodes.d.ts +9 -0
  10. package/dist/api/dto/profiles.d.ts +5 -0
  11. package/dist/api/dto/reports.d.ts +45 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/plugin-manifest-schema.d.ts +208 -0
  15. package/dist/api/plugin-manifest-schema.js +23 -0
  16. package/dist/api/routes.d.ts +7 -0
  17. package/dist/api/routes.js +7 -0
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +24 -3
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +31 -4
  20. package/dist/cli.js +8 -2
  21. package/dist/clients/attach/overlays/graph.d.ts +5 -0
  22. package/dist/clients/attach/overlays/graph.js +65 -47
  23. package/dist/clients/attach/overlays/pickers.js +8 -9
  24. package/dist/clients/attach/render/card-presentation.js +1 -1
  25. package/dist/clients/attach/viewer.js +1286 -1260
  26. package/dist/commands/api-client.js +22 -23
  27. package/dist/commands/node/create.js +38 -3
  28. package/dist/commands/node/inspect.js +3 -7
  29. package/dist/commands/node/message.js +3 -3
  30. package/dist/commands/node/outcome.d.ts +2 -0
  31. package/dist/commands/node/outcome.js +134 -0
  32. package/dist/commands/node.js +2 -1
  33. package/dist/commands/pkg/browse/catalog.js +1 -1
  34. package/dist/commands/pkg/plugin-manage.d.ts +46 -1
  35. package/dist/commands/pkg/plugin-manage.js +170 -20
  36. package/dist/commands/profile/pause.js +17 -14
  37. package/dist/commands/push.js +102 -1
  38. package/dist/commands/sys/__tests__/setup-core.test.js +4 -1
  39. package/dist/commands/sys/panels/broker-limits-panel.js +7 -1
  40. package/dist/commands/sys/panels/models-panel.js +2 -2
  41. package/dist/commands/sys/settings-shell.d.ts +1 -1
  42. package/dist/commands/sys/settings-shell.js +6 -0
  43. package/dist/commands/sys/settings.js +3 -3
  44. package/dist/core/__tests__/fixtures/fake-engine.d.ts +2 -0
  45. package/dist/core/__tests__/fixtures/fake-engine.js +42 -4
  46. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  47. package/dist/core/__tests__/helpers/harness.js +9 -0
  48. package/dist/core/__tests__/integration/plugin-revalidate.test.d.ts +1 -0
  49. package/dist/core/__tests__/integration/plugin-revalidate.test.js +355 -0
  50. package/dist/core/__tests__/integration/profile-pause.test.d.ts +1 -0
  51. package/dist/core/__tests__/integration/profile-pause.test.js +115 -0
  52. package/dist/core/__tests__/node-outcome.test.d.ts +1 -0
  53. package/dist/core/__tests__/node-outcome.test.js +184 -0
  54. package/dist/core/__tests__/revive-parked-fresh.test.js +13 -20
  55. package/dist/core/__tests__/seam/broker-provider-retry.test.js +6 -2
  56. package/dist/core/__tests__/seam/dormancy-release.test.js +3 -3
  57. package/dist/core/__tests__/seam/node-outcome-delivery.test.d.ts +1 -0
  58. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +153 -0
  59. package/dist/core/bash-jobs.d.ts +6 -0
  60. package/dist/core/bash-jobs.js +22 -0
  61. package/dist/core/canvas/canvas.d.ts +41 -3
  62. package/dist/core/canvas/canvas.js +153 -39
  63. package/dist/core/canvas/crons.js +2 -1
  64. package/dist/core/canvas/index.d.ts +1 -0
  65. package/dist/core/canvas/index.js +1 -0
  66. package/dist/core/canvas/migrations.js +47 -0
  67. package/dist/core/canvas/node-outcome-deliveries.d.ts +80 -0
  68. package/dist/core/canvas/node-outcome-deliveries.js +163 -0
  69. package/dist/core/canvas/pid.d.ts +7 -0
  70. package/dist/core/canvas/pid.js +17 -0
  71. package/dist/core/canvas/render-source.js +2 -2
  72. package/dist/core/canvas/types.d.ts +42 -1
  73. package/dist/core/canvas/types.js +2 -1
  74. package/dist/core/command-manifests/registry.d.ts +3 -1
  75. package/dist/core/command-manifests/schema.d.ts +2 -75
  76. package/dist/core/command-manifests/schema.js +5 -0
  77. package/dist/core/command-plugins/compose.js +1 -1
  78. package/dist/core/command-plugins/revalidate.d.ts +18 -0
  79. package/dist/core/command-plugins/revalidate.js +152 -0
  80. package/dist/core/command-plugins/transport/http-fetch.d.ts +29 -4
  81. package/dist/core/command-plugins/transport/http-fetch.js +18 -8
  82. package/dist/core/command-plugins/transport/http-invoke.js +7 -0
  83. package/dist/core/command.d.ts +8 -0
  84. package/dist/core/command.js +19 -3
  85. package/dist/core/config.d.ts +4 -1
  86. package/dist/core/config.js +25 -1
  87. package/dist/core/exclusive-lock.d.ts +12 -0
  88. package/dist/core/exclusive-lock.js +29 -0
  89. package/dist/core/feed/feed.d.ts +8 -0
  90. package/dist/core/feed/feed.js +25 -4
  91. package/dist/core/feed/inbox.d.ts +5 -0
  92. package/dist/core/feed/inbox.js +13 -0
  93. package/dist/core/installed-plugins.js +31 -0
  94. package/dist/core/keybindings/catalog.d.ts +1 -1
  95. package/dist/core/keybindings/catalog.js +3 -0
  96. package/dist/core/manifest-recovery.d.ts +15 -0
  97. package/dist/core/manifest-recovery.js +72 -0
  98. package/dist/core/manifest-stale.d.ts +18 -0
  99. package/dist/core/manifest-stale.js +39 -0
  100. package/dist/core/plugin-swap-lock.d.ts +9 -0
  101. package/dist/core/plugin-swap-lock.js +31 -0
  102. package/dist/core/profiles/manifest.d.ts +3 -1
  103. package/dist/core/profiles/manifest.js +6 -1
  104. package/dist/core/runtime/broker/daemon-ops.d.ts +3 -1
  105. package/dist/core/runtime/broker/daemon-ops.js +6 -0
  106. package/dist/core/runtime/broker/engine-drive.d.ts +2 -3
  107. package/dist/core/runtime/broker/engine-drive.js +74 -27
  108. package/dist/core/runtime/broker/frame-dispatch.js +1 -0
  109. package/dist/core/runtime/broker/read-ops.js +14 -0
  110. package/dist/core/runtime/broker/rebind.js +0 -7
  111. package/dist/core/runtime/broker-protocol.d.ts +1 -6
  112. package/dist/core/runtime/fleet.d.ts +7 -0
  113. package/dist/core/runtime/headless-pi.d.ts +13 -0
  114. package/dist/core/runtime/headless-pi.js +10 -2
  115. package/dist/core/runtime/lifecycle.d.ts +6 -1
  116. package/dist/core/runtime/lifecycle.js +50 -1
  117. package/dist/core/runtime/nodes.d.ts +5 -1
  118. package/dist/core/runtime/nodes.js +2 -1
  119. package/dist/core/runtime/outcome-document.d.ts +70 -0
  120. package/dist/core/runtime/outcome-document.js +97 -0
  121. package/dist/core/runtime/revive.js +16 -21
  122. package/dist/core/runtime/spawn.d.ts +5 -1
  123. package/dist/core/runtime/spawn.js +18 -7
  124. package/dist/core/runtime/stamp/channel.d.ts +3 -5
  125. package/dist/core/runtime/stamp/channel.js +3 -10
  126. package/dist/core/runtime/stop-guard.js +2 -2
  127. package/dist/core/runtime/structured-output.d.ts +24 -3
  128. package/dist/core/runtime/structured-output.js +42 -3
  129. package/dist/core/runtime/tmux-bindings.js +4 -0
  130. package/dist/core/runtime/warm-pool.d.ts +5 -2
  131. package/dist/core/runtime/warm-pool.js +5 -5
  132. package/dist/core/subscription-state.js +7 -5
  133. package/dist/core/substrate/render.js +10 -10
  134. package/dist/core/user-settings.d.ts +18 -1
  135. package/dist/core/user-settings.js +37 -0
  136. package/dist/daemon/__tests__/node-deadline-lane.test.d.ts +1 -0
  137. package/dist/daemon/__tests__/node-deadline-lane.test.js +76 -0
  138. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.d.ts +1 -0
  139. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +122 -0
  140. package/dist/daemon/__tests__/node-outcome-delivery.test.d.ts +1 -0
  141. package/dist/daemon/__tests__/node-outcome-delivery.test.js +86 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +86 -18
  143. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +10 -1
  144. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.d.ts +1 -0
  145. package/dist/daemon/api/__tests__/seam/node-outcome-api.test.js +159 -0
  146. package/dist/daemon/api/__tests__/submit-result.test.d.ts +1 -0
  147. package/dist/daemon/api/__tests__/submit-result.test.js +214 -0
  148. package/dist/daemon/api/handlers/broker-ops.js +53 -24
  149. package/dist/daemon/api/handlers/canvas.js +2 -2
  150. package/dist/daemon/api/handlers/messages.js +12 -2
  151. package/dist/daemon/api/handlers/node-outcomes.d.ts +2 -0
  152. package/dist/daemon/api/handlers/node-outcomes.js +136 -0
  153. package/dist/daemon/api/handlers/nodes.js +67 -1
  154. package/dist/daemon/api/handlers/profiles.js +53 -1
  155. package/dist/daemon/api/handlers/reports.js +197 -33
  156. package/dist/daemon/api/map.d.ts +7 -2
  157. package/dist/daemon/api/map.js +78 -4
  158. package/dist/daemon/api/server.js +2 -0
  159. package/dist/daemon/crtrd.js +12 -0
  160. package/dist/daemon/fleet.d.ts +4 -8
  161. package/dist/daemon/fleet.js +48 -9
  162. package/dist/daemon/messaging/node-message.js +2 -1
  163. package/dist/daemon/node-outcome/deliver-outcome.d.ts +14 -0
  164. package/dist/daemon/node-outcome/deliver-outcome.js +155 -0
  165. package/dist/daemon/park-activity.d.ts +9 -0
  166. package/dist/daemon/park-activity.js +30 -0
  167. package/dist/daemon/park-pending.d.ts +10 -5
  168. package/dist/daemon/park-pending.js +18 -8
  169. package/dist/daemon/reconcilers/bash-deadline.js +6 -0
  170. package/dist/daemon/reconcilers/broker-supervision.d.ts +3 -7
  171. package/dist/daemon/reconcilers/broker-supervision.js +29 -33
  172. package/dist/daemon/reconcilers/node-deadline.d.ts +5 -0
  173. package/dist/daemon/reconcilers/node-deadline.js +82 -0
  174. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -1
  175. package/dist/daemon/reconcilers/node-lifecycle/tick.js +12 -3
  176. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +10 -0
  177. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +41 -0
  178. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +66 -66
  179. package/dist/pi-extensions/canvas-structured-output.d.ts +9 -5
  180. package/dist/pi-extensions/canvas-structured-output.js +23 -297
  181. package/dist/shared/env.d.ts +3 -0
  182. package/dist/shared/env.js +6 -0
  183. package/dist/shared/generated-context.d.ts +3 -9
  184. package/dist/shared/generated-context.js +8 -16
  185. package/dist/types.d.ts +19 -0
  186. package/dist/types.js +1 -0
  187. package/package.json +9 -8
  188. package/runtime.lock.json +227 -383
@@ -19,7 +19,7 @@ import { fullName } from './labels.js';
19
19
  import { jobDir, contextDir, reportsDir } from './paths.js';
20
20
  import { readNodeRecap } from './node-recap.js';
21
21
  import { parseFrontmatterGeneric } from '../frontmatter.js';
22
- import { isPidAlive } from './pid.js';
22
+ import { isPidAlive, isPidPresent } from './pid.js';
23
23
  import { isFrozenRow, resolveNodeVisual, faultSummary } from './status-glyph.js';
24
24
  import { readFault } from '../runtime/fault.js';
25
25
  import { parseCard } from '../../shared/generated-context.js';
@@ -434,7 +434,7 @@ function assistantContentIsSubstantive(content) {
434
434
  * (an active node is usually dormant between turns). Read directly off disk
435
435
  * (mirrors telemetry) to avoid inverting the canvas→runtime dependency. */
436
436
  export function isStreaming(nodeId, piPid) {
437
- if (!isPidAlive(piPid))
437
+ if (!isPidPresent(piPid))
438
438
  return false;
439
439
  try {
440
440
  return existsSync(join(jobDir(nodeId), 'busy'));
@@ -18,7 +18,34 @@ export type Lifecycle = 'terminal' | 'resident';
18
18
  export type Mode = 'base' | 'orchestrator';
19
19
  /** Why a node last stopped — drives the daemon's reap-vs-revive decision. */
20
20
  export type ExitIntent = 'done' | 'refresh' | 'idle-release' | 'parked' | null;
21
- export type TerminalReason = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded';
21
+ export type TerminalReason = 'finalized' | 'finished' | 'parked' | 'closed' | 'retired' | 'crashed' | 'stranded' | 'boot_failed' | 'crash_looped' | 'launch_failed' | 'context_overflow' | 'deadline_exceeded' | 'provider_fatal' | 'declined';
22
+ export type NodeOutcomeKind = 'result' | 'failure';
23
+ /** Maximum serialized opaque action payload; enforced at registration in Phase 5. */
24
+ export declare const OUTCOME_PAYLOAD_MAX_BYTES: number;
25
+ export interface NodeOutcomeDetailV1 {
26
+ schema: 'crtr.node-outcome-detail/v1';
27
+ message?: string;
28
+ fault_kind?: import('../fault-classifier.js').FaultKind;
29
+ error_class?: import('../events/types.js').ErrorClassCode;
30
+ retry?: {
31
+ attempt: number;
32
+ max: number;
33
+ };
34
+ respawn_failures?: number;
35
+ deadline?: {
36
+ deadline_at: string;
37
+ elapsed_ms: number;
38
+ };
39
+ /** Present only for reason `declined`: what the node gave `crtr push result --decline`. */
40
+ declined?: DeclinedResult;
41
+ truncated?: true;
42
+ }
43
+ /** A declined structured result. `code` is opaque here — the requester validates it. */
44
+ export interface DeclinedResult {
45
+ reason: string;
46
+ code: string;
47
+ retryable: boolean;
48
+ }
22
49
  /** The two structural edges. `subscribes_to` is the load-bearing spine (flow,
23
50
  * org chart, views, completion routing). `spawned_by` is audit only. */
24
51
  export type EdgeType = 'subscribes_to' | 'spawned_by';
@@ -266,6 +293,14 @@ export interface NodeRuntime {
266
293
  final_report?: string | null;
267
294
  /** ISO timestamp carried by the committed canonical final report, or null. */
268
295
  finalized_at?: string | null;
296
+ /** Durable terminal outcome. `outcome_at` is its write-once latch. */
297
+ outcome_kind?: NodeOutcomeKind | null;
298
+ outcome_reason?: TerminalReason | null;
299
+ outcome_at?: string | null;
300
+ outcome_revision?: number;
301
+ outcome_detail_json?: string | null;
302
+ /** Absolute wall-clock spawn deadline, or null when unbounded. */
303
+ deadline_at?: string | null;
269
304
  /** OS pid of the live broker process, recorded on boot (stophook session_start).
270
305
  * The daemon's authoritative liveness signal — every node is a detached broker,
271
306
  * so `isPidAlive(pi_pid)` is the sole liveness check. Cleared to null by
@@ -342,6 +377,12 @@ export interface NodeRow {
342
377
  terminal_reason?: TerminalReason | null;
343
378
  final_report?: string | null;
344
379
  finalized_at?: string | null;
380
+ outcome_kind?: NodeOutcomeKind | null;
381
+ outcome_reason?: TerminalReason | null;
382
+ outcome_at?: string | null;
383
+ outcome_revision?: number;
384
+ outcome_detail_json?: string | null;
385
+ deadline_at?: string | null;
345
386
  pi_pid: number | null;
346
387
  /** See `NodeRuntime.pi_pid_identity`. */
347
388
  pi_pid_identity: string | null;
@@ -5,4 +5,5 @@
5
5
  // `meta.json` is the source of truth for its own row; the db is a queryable
6
6
  // index over those metas, plus the authoritative store for the mutable
7
7
  // `subscribes_to` edges (which no single meta owns).
8
- export {};
8
+ /** Maximum serialized opaque action payload; enforced at registration in Phase 5. */
9
+ export const OUTCOME_PAYLOAD_MAX_BYTES = 64 * 1024;
@@ -24,7 +24,9 @@ export type LeafAdapter = (leaf: DeclLeafBase, commandPath: readonly string[]) =
24
24
  * will produce its leaves' run implementations. */
25
25
  export interface CommandContribution {
26
26
  contributor: CommandContributorRef;
27
- node: DeclBranch<DeclLeafBase>;
27
+ /** Every leaf below this branch carries its transport dialect — a validated
28
+ * contribution is never a bare {@link DeclLeafBase}. */
29
+ node: DeclBranch;
28
30
  /** Root name from the manifest, before collision qualification. */
29
31
  manifestName: string;
30
32
  adaptLeaf: LeafAdapter;
@@ -1,77 +1,5 @@
1
- import type { InputParam, Field } from '../help.js';
2
- export interface DeclRootEntry {
3
- concept: string;
4
- description: string;
5
- whenToUse: string;
6
- }
7
- export interface DeclBranch<L = DeclLeafBase> {
8
- kind: 'branch';
9
- name: string;
10
- description: string;
11
- whenToUse: string;
12
- tier?: 'normal' | 'common' | 'important';
13
- /** Required on a top-level branch, forbidden on a nested one. */
14
- rootEntry?: DeclRootEntry;
15
- /** Allows the nearest repository fragment to contribute children below this top-level branch. */
16
- extensible?: true;
17
- summary: string;
18
- model?: string;
19
- /** Exec transport only: forward every argv token after this branch to an
20
- * external binary instead of parsing children. A passthrough branch is
21
- * childless by construction. HTTP manifests reject passthrough because an
22
- * HTTP transport must not name a local binary to execute. */
23
- passthrough?: DeclPassthrough;
24
- children: DeclNode<L>[];
25
- }
26
- export interface DeclPassthrough {
27
- bin: string;
28
- installHint: string;
29
- }
30
- export interface DeclLeafBase {
31
- kind: 'leaf';
32
- name: string;
33
- description: string;
34
- whenToUse: string;
35
- tier?: 'normal' | 'common' | 'important';
36
- summary: string;
37
- params: InputParam[];
38
- output: Field[];
39
- effects: string[];
40
- }
41
- /** Exec-transport leaf: requires outputKind: 'object'. */
42
- export interface ExecDeclLeaf extends DeclLeafBase {
43
- outputKind: 'object';
44
- }
45
- /** HTTP-transport plugin REST mapping (placeholder types; full spec in manifest.ts). */
46
- export type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
47
- export type RestParamPlacement = 'path' | 'query' | 'body' | 'header';
48
- export interface RestParamMapping {
49
- in: RestParamPlacement;
50
- as?: string;
51
- }
52
- export interface RestMapping {
53
- method: RestMethod;
54
- path: string;
55
- streaming?: boolean;
56
- /** Constant literal body fields merged into the request body (top-level, alongside
57
- * any bodyRoot-nested param values). Forbidden on GET. */
58
- body?: Record<string, string | number | boolean>;
59
- /** When set, all in:"body" param values nest under this key instead of the body
60
- * top level (constants from `body` stay top-level regardless). Forbidden on GET. */
61
- bodyRoot?: string;
62
- params: Record<string, RestParamMapping>;
63
- }
64
- export interface ManifestTimeouts {
65
- connectMs?: number;
66
- requestMs?: number;
67
- streamIdleMs?: number;
68
- }
69
- /** HTTP-transport leaf: requires rest (derives outputKind from streaming). */
70
- export interface HttpDeclLeaf extends DeclLeafBase {
71
- rest: RestMapping;
72
- }
73
- export type DeclLeaf = ExecDeclLeaf | HttpDeclLeaf;
74
- export type DeclNode<L = DeclLeafBase> = DeclBranch<L> | (L extends DeclLeafBase ? DeclLeaf : never);
1
+ import type { ManifestRootEntry as DeclRootEntry, ManifestPassthrough as DeclPassthrough, ManifestLeafBase as DeclLeafBase, ManifestExecLeaf as ExecDeclLeaf, ManifestHttpLeaf as HttpDeclLeaf, ManifestLeaf as DeclLeaf, ManifestBranch as DeclBranch, ManifestNode as DeclNode, RestMethod, RestParamPlacement, RestParamMapping, RestMapping, ManifestTimeouts } from '../../api/plugin-manifest-schema.js';
2
+ export type { DeclRootEntry, DeclPassthrough, DeclLeafBase, ExecDeclLeaf, HttpDeclLeaf, DeclLeaf, DeclBranch, DeclNode, RestMethod, RestParamPlacement, RestParamMapping, RestMapping, ManifestTimeouts, };
75
3
  export interface CommandManifestIssue {
76
4
  code: CommandIssueCode;
77
5
  path?: string;
@@ -92,4 +20,3 @@ export interface CommandNodeValidationOptions {
92
20
  allowExtensible?: boolean;
93
21
  }
94
22
  export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn, options?: CommandNodeValidationOptions): DeclBranch<DeclLeaf> | DeclLeaf | null;
95
- export {};
@@ -1,4 +1,9 @@
1
1
  // Unified command-manifest schema. Transport is the sole leaf-dialect discriminator.
2
+ //
3
+ // The SHAPE of a manifest is declared once, in `src/api/plugin-manifest-schema.ts`,
4
+ // and published as `@north-light/crouter-api/plugin-manifest` so a server that
5
+ // serves a plugin bundle compiles against the same types validated here. This
6
+ // module owns the validators and re-exports those types under their in-tree names.
2
7
  import { isRecord } from '../../shared/predicates.js';
3
8
  // Validation helpers
4
9
  const TIERS = new Set(['normal', 'common', 'important']);
@@ -43,5 +43,5 @@ function buildBranch(contribution, node, path) {
43
43
  }
44
44
  function buildLeaf(contribution, node, path) {
45
45
  const adapted = contribution.adaptLeaf(node, path);
46
- return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: node.effects }, run: adapted.run });
46
+ return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
47
47
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Bring every installed bundle plugin whose last check has aged out back in
3
+ * step with its endpoint. Never throws: a plugin that cannot be revalidated
4
+ * keeps the package already unpacked on disk, which is a complete and valid
5
+ * command tree, and the failure is reported on stderr rather than replacing the
6
+ * caller's command with an error about a background check.
7
+ */
8
+ export declare function revalidateBundlePlugins(argv: readonly string[]): Promise<void>;
9
+ /**
10
+ * Force one plugin back in step with its endpoint, ignoring both the TTL and
11
+ * the stored validator. Recovery from a server that answered `manifest_stale`:
12
+ * the description the caller invoked from is out of date, so the check that
13
+ * would normally be skipped is exactly the one that has to run.
14
+ *
15
+ * Returns whether the package on disk actually changed, so a caller can tell a
16
+ * recoverable staleness from an operation that is genuinely gone.
17
+ */
18
+ export declare function forceRevalidateBundlePlugin(name: string): Promise<boolean>;
@@ -0,0 +1,152 @@
1
+ // TTL-gated revalidation of installed bundle plugins.
2
+ //
3
+ // A bundle plugin's package is fetched once, at install. Without this pass a
4
+ // long-lived machine keeps invoking the command tree it installed on day one,
5
+ // so a leaf the server renamed fails from the client side with no way back
6
+ // except a human running `crtr pkg plugin update`.
7
+ //
8
+ // The pass runs before the command tree is built, so a replacement it performs
9
+ // is what this very invocation dispatches against. Being on that path, it is
10
+ // also bound by what it may DRAG there: this module reaches only the leaf-level
11
+ // scope, config, and installed-plugin readers — never `discovery.js`, which
12
+ // pulls the manifest validators, the registry, and the composer onto every
13
+ // single crtr invocation, nor `plugin-manage.js`, which pulls the whole package
14
+ // command graph. The machinery that fetches and swaps a package is imported
15
+ // only once a plugin is found due.
16
+ import { readState } from '../config.js';
17
+ import { listInstalledPluginsInRoot } from '../installed-plugins.js';
18
+ import { scopeRoot } from '../scope.js';
19
+ import { GLOBAL_TOKENS } from '../command.js';
20
+ const DEFAULT_TTL_MS = 15 * 60 * 1000;
21
+ /** Project first, so a project-scoped package shadows a user-scoped one of the
22
+ * same name exactly as the command tree resolves it. */
23
+ const SCOPES = ['project', 'user'];
24
+ /** How long a recorded check stands before the next invocation re-probes.
25
+ * `CRTR_PLUGIN_REVALIDATE_TTL_MS=0` disables the pass outright — which is how
26
+ * a test, or any process that must not reach the network, opts out. */
27
+ function ttlMs() {
28
+ const raw = process.env['CRTR_PLUGIN_REVALIDATE_TTL_MS'];
29
+ if (raw === undefined || raw.trim() === '')
30
+ return DEFAULT_TTL_MS;
31
+ const parsed = Number(raw);
32
+ if (!Number.isFinite(parsed) || parsed < 0)
33
+ return DEFAULT_TTL_MS;
34
+ return parsed;
35
+ }
36
+ /** A check is fresh for `ttl` after it happened. A `checked_at` in the FUTURE
37
+ * did not happen: a clock that jumped back, or a file copied from another
38
+ * machine, must not be able to suppress the pass until the clock catches up. */
39
+ function isFresh(checkedAt, ttl, now) {
40
+ if (checkedAt === undefined)
41
+ return false;
42
+ const at = Date.parse(checkedAt);
43
+ if (Number.isNaN(at))
44
+ return false;
45
+ const age = now - at;
46
+ return age >= 0 && age < ttl;
47
+ }
48
+ /** A command that manages packages does its own fetching, with its own reporting
49
+ * and its own exit codes. Revalidating underneath it would race that work and
50
+ * make the leaf's result describe a package it did not install. `crtr --json
51
+ * pkg …` is that same command: the global tokens the dispatcher strips are not
52
+ * part of the command path here either. */
53
+ function managesPackages(argv) {
54
+ return argv.slice(2).find((token) => !GLOBAL_TOKENS.has(token)) === 'pkg';
55
+ }
56
+ /** Every bundle plugin whose last check has aged out, resolved from the same
57
+ * scope root whose state ledger records the check — so the plugin read and the
58
+ * `checked_at` write can never describe different roots. */
59
+ function duePlugins(ttl, now) {
60
+ const due = [];
61
+ const claimed = new Set();
62
+ for (const scope of SCOPES) {
63
+ const root = scopeRoot(scope);
64
+ if (root === null)
65
+ continue;
66
+ const state = readState(scope);
67
+ for (const plugin of listInstalledPluginsInRoot(scope, root)) {
68
+ if (claimed.has(plugin.name))
69
+ continue;
70
+ claimed.add(plugin.name);
71
+ const bundle = plugin.manifest.bundle;
72
+ if (!plugin.enabled || bundle === undefined)
73
+ continue;
74
+ if (isFresh(state.plugins[plugin.name]?.checked_at, ttl, now))
75
+ continue;
76
+ due.push({ name: plugin.name, scope, bundle, enabled: plugin.enabled });
77
+ }
78
+ }
79
+ return due;
80
+ }
81
+ /**
82
+ * Bring every installed bundle plugin whose last check has aged out back in
83
+ * step with its endpoint. Never throws: a plugin that cannot be revalidated
84
+ * keeps the package already unpacked on disk, which is a complete and valid
85
+ * command tree, and the failure is reported on stderr rather than replacing the
86
+ * caller's command with an error about a background check.
87
+ */
88
+ export async function revalidateBundlePlugins(argv) {
89
+ const ttl = ttlMs();
90
+ if (ttl === 0 || managesPackages(argv))
91
+ return;
92
+ let due;
93
+ try {
94
+ due = duePlugins(ttl, Date.now());
95
+ }
96
+ catch {
97
+ // Enumeration reads config, state, and every installed plugin.json. A scope
98
+ // this process cannot read is a scope it also cannot revalidate, and the
99
+ // command it was invoked for may not need plugins at all.
100
+ return;
101
+ }
102
+ if (due.length === 0)
103
+ return;
104
+ const { revalidateBundlePlugin } = await import('../../commands/pkg/plugin-manage.js');
105
+ for (const plugin of due) {
106
+ try {
107
+ await revalidateBundlePlugin(plugin.name, plugin.bundle, plugin.scope, {
108
+ enable: plugin.enabled,
109
+ conditional: true,
110
+ timeoutMs: 2_000,
111
+ });
112
+ }
113
+ catch (error) {
114
+ const detail = error instanceof Error ? error.message : String(error);
115
+ process.stderr.write(`crtr: plugin "${plugin.name}" could not be revalidated (${detail}); using the installed package.\n`);
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Force one plugin back in step with its endpoint, ignoring both the TTL and
121
+ * the stored validator. Recovery from a server that answered `manifest_stale`:
122
+ * the description the caller invoked from is out of date, so the check that
123
+ * would normally be skipped is exactly the one that has to run.
124
+ *
125
+ * Returns whether the package on disk actually changed, so a caller can tell a
126
+ * recoverable staleness from an operation that is genuinely gone.
127
+ */
128
+ export async function forceRevalidateBundlePlugin(name) {
129
+ let plugin;
130
+ for (const scope of SCOPES) {
131
+ const root = scopeRoot(scope);
132
+ if (root === null)
133
+ continue;
134
+ plugin = listInstalledPluginsInRoot(scope, root).find((candidate) => candidate.name === name);
135
+ if (plugin !== undefined)
136
+ break;
137
+ }
138
+ const bundle = plugin?.manifest.bundle;
139
+ if (plugin === undefined || bundle === undefined)
140
+ return false;
141
+ const { revalidateBundlePlugin } = await import('../../commands/pkg/plugin-manage.js');
142
+ // An unconditional refetch either swaps the package or reports that the bytes
143
+ // the server served are byte-identical to the ones already unpacked. Both
144
+ // answers are the swap's own, so `undefined` here means "nothing changed" —
145
+ // the operation the caller invoked is genuinely gone, and the server's error
146
+ // stands.
147
+ const replaced = await revalidateBundlePlugin(name, bundle, plugin.scope, {
148
+ enable: plugin.enabled,
149
+ conditional: false,
150
+ });
151
+ return replaced !== undefined;
152
+ }
@@ -3,14 +3,39 @@ import type { HttpPluginFetchTarget } from '../endpoint.js';
3
3
  export interface FetchSuccess {
4
4
  status: 200;
5
5
  raw: Uint8Array;
6
+ /** The server's validator for these exact bytes, when it sent one. Storing it
7
+ * is what lets the next fetch be conditional. */
8
+ etag?: string;
6
9
  }
7
- export type FetchResult = FetchSuccess | FetchFailure;
10
+ /** The archive the caller already holds is still current. Only ever returned
11
+ * when the caller supplied `ifNoneMatch`. */
12
+ export interface FetchNotModified {
13
+ status: 304;
14
+ }
15
+ export type FetchResult = FetchSuccess | FetchNotModified | FetchFailure;
8
16
  export interface FetchFailure {
9
17
  status: 'auth_env_missing' | 'cli_unreachable' | 'cli_protocol_error';
10
18
  message: string;
11
19
  }
20
+ export interface FetchOptions {
21
+ /** Send as `If-None-Match`, inviting the server to answer 304 instead of
22
+ * resending an archive the caller already has unpacked. */
23
+ ifNoneMatch?: string;
24
+ /** Inactivity timeout. Defaults to ten seconds, which suits an install the
25
+ * caller is waiting on; a revalidation on an ordinary command's latency path
26
+ * passes something far shorter. */
27
+ timeoutMs?: number;
28
+ }
12
29
  /**
13
- * One authenticated GET for a plugin directory archive. It has one ten-second
14
- * inactivity timeout and never retries or conditionally revalidates.
30
+ * One authenticated GET for a plugin directory archive, conditional when the
31
+ * caller supplies a validator. It never retries.
32
+ *
33
+ * A caller that sends no validator cannot be answered 304 — there is nothing
34
+ * for the server to compare against — and the overloads say so, so an
35
+ * unconditional caller handles the two outcomes it can actually get instead of
36
+ * carrying an unreachable branch for the third.
15
37
  */
16
- export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget): Promise<FetchResult>;
38
+ export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget, options?: FetchOptions & {
39
+ ifNoneMatch?: never;
40
+ }): Promise<FetchSuccess | FetchFailure>;
41
+ export declare function fetchHttpPluginBundle(registration: HttpPluginFetchTarget, options: FetchOptions): Promise<FetchResult>;
@@ -1,11 +1,7 @@
1
1
  import { URL } from 'node:url';
2
2
  import { request as httpsRequest } from 'node:https';
3
3
  import { request as httpRequest } from 'node:http';
4
- /**
5
- * One authenticated GET for a plugin directory archive. It has one ten-second
6
- * inactivity timeout and never retries or conditionally revalidates.
7
- */
8
- export async function fetchHttpPluginBundle(registration) {
4
+ export async function fetchHttpPluginBundle(registration, options = {}) {
9
5
  let token;
10
6
  if (registration.authEnv) {
11
7
  token = process.env[registration.authEnv];
@@ -26,20 +22,34 @@ export async function fetchHttpPluginBundle(registration) {
26
22
  message: `Invalid endpoint URL: ${registration.endpoint}`,
27
23
  };
28
24
  }
25
+ const timeoutMs = options.timeoutMs ?? 10_000;
29
26
  const headers = { Accept: 'application/x-tar' };
30
27
  if (token)
31
28
  headers['Authorization'] = `Bearer ${token}`;
29
+ if (options.ifNoneMatch !== undefined)
30
+ headers['If-None-Match'] = options.ifNoneMatch;
32
31
  return new Promise((resolve) => {
33
32
  const request = url.protocol === 'https:' ? httpsRequest : httpRequest;
34
- const req = request(url, { method: 'GET', headers, timeout: 10_000 }, (res) => {
33
+ const req = request(url, { method: 'GET', headers, timeout: timeoutMs }, (res) => {
35
34
  let body = Buffer.alloc(0);
36
35
  res.on('data', (chunk) => { body = Buffer.concat([body, chunk]); });
37
36
  res.on('end', () => {
37
+ const etag = res.headers['etag'];
38
38
  if (!res.statusCode) {
39
39
  resolve({ status: 'cli_protocol_error', message: 'No HTTP status code received.' });
40
40
  }
41
+ else if (res.statusCode === 304) {
42
+ // Unsolicited: the caller has no archive this could be affirming, so
43
+ // there is nothing on disk for a bare 304 to mean.
44
+ if (options.ifNoneMatch === undefined) {
45
+ resolve({ status: 'cli_protocol_error', message: `HTTP 304 from ${registration.endpoint} without a conditional request` });
46
+ }
47
+ else {
48
+ resolve({ status: 304 });
49
+ }
50
+ }
41
51
  else if (res.statusCode >= 200 && res.statusCode < 300) {
42
- resolve({ status: 200, raw: new Uint8Array(body) });
52
+ resolve({ status: 200, raw: new Uint8Array(body), ...(typeof etag === 'string' ? { etag } : {}) });
43
53
  }
44
54
  else {
45
55
  resolve({ status: 'cli_protocol_error', message: `HTTP ${res.statusCode} from ${registration.endpoint}` });
@@ -48,7 +58,7 @@ export async function fetchHttpPluginBundle(registration) {
48
58
  });
49
59
  req.on('timeout', () => {
50
60
  req.destroy();
51
- resolve({ status: 'cli_unreachable', message: `Request timeout (10s) fetching ${registration.endpoint}` });
61
+ resolve({ status: 'cli_unreachable', message: `Request timeout (${timeoutMs}ms) fetching ${registration.endpoint}` });
52
62
  });
53
63
  req.on('error', (error) => {
54
64
  resolve({ status: 'cli_unreachable', message: `Network error fetching ${registration.endpoint}: ${error.message}` });
@@ -17,6 +17,7 @@ import { request as httpRequest } from 'node:http';
17
17
  import { request as httpsRequest } from 'node:https';
18
18
  import { validateDeclaredResult } from './exec-invoke.js';
19
19
  import { CrtrError } from '../../errors.js';
20
+ import { envelopeClaimsStale, staleErrorDetails } from '../../manifest-stale.js';
20
21
  import { diag, recordPreviewError, recordPreviewJsonLine, writeStdout } from '../../io.js';
21
22
  import { ExitCode } from '../../../types.js';
22
23
  import { isRecord } from '../../../shared/predicates.js';
@@ -479,9 +480,15 @@ function throwNon2xx(spec, status, body) {
479
480
  const field = typeof err['field'] === 'string' ? err['field'] : undefined;
480
481
  const next = typeof err['next'] === 'string' ? err['next'] : `Inspect the backend response, then retry if appropriate.`;
481
482
  const accepted = SNAKE.test(code) && !RESERVED_CODES.has(code);
483
+ // A generic recovery hint, not a backend-specific code: the server is
484
+ // saying the command description this call was parsed from is out of
485
+ // date. The dispatcher acts on it by refetching the plugin's package and
486
+ // re-running the original argv against the refreshed tree.
487
+ const manifestStale = envelopeClaimsStale(err);
482
488
  throw new CrtrError(accepted ? code : 'cli_protocol_error', message, exit, {
483
489
  ...(err['received'] !== undefined ? { received: err['received'] } : {}),
484
490
  ...(field !== undefined ? { field } : {}),
491
+ ...(manifestStale ? staleErrorDetails(spec.registration.name) : {}),
485
492
  http_status: status,
486
493
  next,
487
494
  });
@@ -173,5 +173,13 @@ export interface ParseArgvOptions {
173
173
  * Returns a plain object whose keys are camelCase parameter names.
174
174
  * Optionally tracks which parameters were explicitly provided via a callback. */
175
175
  export declare function parseArgv(params: InputParam[], tokens: string[], options?: ParseArgvOptions): Promise<Record<string, unknown>>;
176
+ /**
177
+ * Tokens handled before dispatch and stripped from what the leaf schema parses
178
+ * (root `-h` renders them as the Globals footer). They belong to no command, so
179
+ * anything reasoning about which command an argv names — the plugin
180
+ * revalidation gate, for one — has to ignore them too. One declaration, so a
181
+ * new global cannot be added here and missed there.
182
+ */
183
+ export declare const GLOBAL_TOKENS: ReadonlySet<string>;
176
184
  export declare function runCli(root: RootDef, argv: string[]): Promise<void>;
177
185
  export {};
@@ -8,6 +8,7 @@ import { renderRoot, renderBranch, renderLeafArgv } from './help.js';
8
8
  import { beginPreview, publishPreviewResult, readStdinRaw, peekStdinRaw, emit, handle, setJsonOutput, isJsonOutput } from './io.js';
9
9
  import { renderResult } from './render.js';
10
10
  import { CrtrError } from './errors.js';
11
+ import { isManifestStaleError } from './manifest-stale.js';
11
12
  import { operationIdContext } from './events/operation-id.js';
12
13
  import { ExitCode } from '../types.js';
13
14
  import { readFileSync } from 'node:fs';
@@ -594,6 +595,14 @@ export async function parseArgv(params, tokens, options) {
594
595
  }
595
596
  return result;
596
597
  }
598
+ /**
599
+ * Tokens handled before dispatch and stripped from what the leaf schema parses
600
+ * (root `-h` renders them as the Globals footer). They belong to no command, so
601
+ * anything reasoning about which command an argv names — the plugin
602
+ * revalidation gate, for one — has to ignore them too. One declaration, so a
603
+ * new global cannot be added here and missed there.
604
+ */
605
+ export const GLOBAL_TOKENS = new Set(['--json', '--no-autostart']);
597
606
  export async function runCli(root, argv) {
598
607
  // argv is process.argv — strip node binary + script path. `--json` is a
599
608
  // global: pull it out anywhere it appears so the rest of argv parses against
@@ -607,10 +616,9 @@ export async function runCli(root, argv) {
607
616
  // to every API-backed verb, so strip it here rather than declaring it on each
608
617
  // leaf schema (an undeclared flag would otherwise be rejected as unknown).
609
618
  const rawTokens = argv.slice(2);
610
- const jsonStripped = rawTokens.filter((t) => t !== '--json');
611
- if (jsonStripped.length !== rawTokens.length)
619
+ if (rawTokens.includes('--json'))
612
620
  setJsonOutput(true);
613
- const tokens = jsonStripped.filter((t) => t !== '--no-autostart');
621
+ const tokens = rawTokens.filter((token) => !GLOBAL_TOKENS.has(token));
614
622
  // Bare root invocation or -h at root
615
623
  if (tokens.length === 0 || (tokens.length === 1 && (tokens[0] === '-h' || tokens[0] === '--help'))) {
616
624
  process.stdout.write(renderRoot(root.help) + '\n');
@@ -686,6 +694,14 @@ export async function runCli(root, argv) {
686
694
  // JSONL leaves call emitLine themselves and return void
687
695
  }
688
696
  catch (e) {
697
+ // A backend that answered `manifest_stale` says this dispatch was parsed
698
+ // from an out-of-date command description. Only the caller above this one
699
+ // can act on that — refetching the package and re-walking the ORIGINAL argv
700
+ // against the refreshed tree — so it is the single error class this
701
+ // dispatcher reports by rethrowing instead of rendering. Whoever declines
702
+ // to recover renders it with the same `handle`.
703
+ if (isManifestStaleError(e))
704
+ throw e;
689
705
  handle(e);
690
706
  }
691
707
  }
@@ -1,4 +1,4 @@
1
- import type { PageComponentRegistration, Scope, ScopeConfig, ScopeState, HumanActionConfig, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
1
+ import type { PageComponentRegistration, Scope, ScopeConfig, ScopeState, HumanActionConfig, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig, ProviderOptionsConfig } from '../types.js';
2
2
  import { type BindingId } from './keybindings/catalog.js';
3
3
  export declare function configPath(scope: Scope): string | null;
4
4
  export declare function statePath(scope: Scope): string | null;
@@ -111,6 +111,9 @@ export declare function readRawScopeConfig(scope: Scope): Partial<ScopeConfig> |
111
111
  export interface MergedLaunchConfig {
112
112
  kinds: Record<string, KindConfig>;
113
113
  modelLadders: ModelLaddersConfig;
114
+ /** Per-provider behavior options keyed by runtime provider id, merged
115
+ * per provider across scopes (project stack > profile > user). */
116
+ providerOptions: Record<string, ProviderOptionsConfig>;
114
117
  /** Complete declared routes. Every route here has all four strengths. */
115
118
  modelRoutes?: Record<string, ModelRouteConfig>;
116
119
  /** Read-only compatibility view for sparse routes written before complete